WIP SSO KA ID + favoris committé depuis le nœud — foodka/auth.py, hubfav, hubprofile, favorites.tsx, Profil.tsx, MAJ web.py + frontend
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
11 changed files +1,109 −10
added
foodka/auth.py
+224 −0
@@ -0,0 +1,224 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (hub groupe-ka.com) | |
| 5 | +# | |
| 6 | +# Identité PARTAGÉE Groupe-Ka : le hub (https://www.groupe-ka.com) authentifie | |
| 7 | +# l'utilisateur et le renvoie ici avec un JWT HS256 signé du secret partagé | |
| 8 | +# KA_SSO_SECRET. Le profil (ka_id « ka-0123456789 », courriel, nom, photo) | |
| 9 | +# est LE MÊME sur toutes les plateformes du groupe. | |
| 10 | +# | |
| 11 | +# Session locale : JWT HS256 maison (hmac/base64, aucune dépendance) signé | |
| 12 | +# avec SESSION_SECRET, cookie httponly 30 jours. | |
| 13 | +# Config .env : KA_SSO_SECRET, KA_HUB_URL, FOODKA_BASE_URL, SESSION_SECRET. | |
| 14 | +# ----------------------------------------------------------------------------- | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import base64 | |
| 18 | +import hashlib | |
| 19 | +import hmac | |
| 20 | +import json | |
| 21 | +import os | |
| 22 | +import time | |
| 23 | +import urllib.parse | |
| 24 | + | |
| 25 | +from fastapi import APIRouter, Request | |
| 26 | +from fastapi.responses import JSONResponse, RedirectResponse | |
| 27 | + | |
| 28 | +from . import db | |
| 29 | +from .hubprofile import fetch_hub_profile, to_epoch | |
| 30 | + | |
| 31 | +router = APIRouter(prefix="/api/auth") | |
| 32 | + | |
| 33 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | |
| 34 | +KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "") | |
| 35 | +# base publique de CETTE app (l'URL de rappel en découle) | |
| 36 | +BASE_URL = os.environ.get("FOODKA_BASE_URL", "http://localhost:8097").rstrip("/") | |
| 37 | +SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256( | |
| 38 | + (KA_SSO_SECRET or "foodka-dev").encode()).hexdigest() | |
| 39 | +COOKIE = "foodka_session" | |
| 40 | +SESSION_DAYS = 30 | |
| 41 | + | |
| 42 | + | |
| 43 | +# -- JWT HS256 minimal (aucune dépendance) ----------------------------------- | |
| 44 | +def _b64(d: bytes) -> str: | |
| 45 | + return base64.urlsafe_b64encode(d).rstrip(b"=").decode() | |
| 46 | + | |
| 47 | + | |
| 48 | +def _unb64(s: str) -> bytes: | |
| 49 | + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) | |
| 50 | + | |
| 51 | + | |
| 52 | +def jwt_encode(payload: dict) -> str: | |
| 53 | + head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) | |
| 54 | + body = _b64(json.dumps(payload, separators=(",", ":")).encode()) | |
| 55 | + sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) | |
| 56 | + return f"{head}.{body}.{sig}" | |
| 57 | + | |
| 58 | + | |
| 59 | +def jwt_decode(token: str) -> dict | None: | |
| 60 | + try: | |
| 61 | + head, body, sig = token.split(".") | |
| 62 | + good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) | |
| 63 | + if not hmac.compare_digest(sig, good): | |
| 64 | + return None | |
| 65 | + payload = json.loads(_unb64(body)) | |
| 66 | + if payload.get("exp", 0) < time.time(): | |
| 67 | + return None | |
| 68 | + return payload | |
| 69 | + except Exception: | |
| 70 | + return None | |
| 71 | + | |
| 72 | + | |
| 73 | +def _ka_verify(token: str) -> dict | None: | |
| 74 | + """Vérifie le JWT HS256 émis par le hub KA (stdlib seulement) : | |
| 75 | + signature, alg, iss, aud, exp.""" | |
| 76 | + if not KA_SSO_SECRET: | |
| 77 | + return None | |
| 78 | + try: | |
| 79 | + head, body, sig = token.split(".") | |
| 80 | + good = _b64(hmac.new(KA_SSO_SECRET.encode(), | |
| 81 | + f"{head}.{body}".encode(), hashlib.sha256).digest()) | |
| 82 | + if not hmac.compare_digest(sig, good): | |
| 83 | + return None | |
| 84 | + if json.loads(_unb64(head)).get("alg") != "HS256": | |
| 85 | + return None | |
| 86 | + claims = json.loads(_unb64(body)) | |
| 87 | + if claims.get("iss") != KA_HUB_URL: | |
| 88 | + return None | |
| 89 | + if claims.get("aud") != "food-ka": | |
| 90 | + return None | |
| 91 | + if claims.get("exp", 0) < time.time(): | |
| 92 | + return None | |
| 93 | + return claims | |
| 94 | + except Exception: | |
| 95 | + return None | |
| 96 | + | |
| 97 | + | |
| 98 | +# -- table users (clé = KA-ID du groupe, identique sur toutes les apps) ------- | |
| 99 | +def _ensure_table(con) -> None: | |
| 100 | + con.execute("""CREATE TABLE IF NOT EXISTS users ( | |
| 101 | + ka_id TEXT PRIMARY KEY, -- identifiant membre Groupe KA (« ka-0123456789 ») | |
| 102 | + email TEXT, | |
| 103 | + name TEXT, | |
| 104 | + picture TEXT, | |
| 105 | + provider TEXT, | |
| 106 | + created REAL, | |
| 107 | + last_login REAL | |
| 108 | + )""") | |
| 109 | + | |
| 110 | + | |
| 111 | +def _upsert_user(info: dict) -> dict: | |
| 112 | + con = db.connect() | |
| 113 | + try: | |
| 114 | + _ensure_table(con) | |
| 115 | + now = time.time() | |
| 116 | + con.execute( | |
| 117 | + "INSERT INTO users (ka_id, email, name, picture, provider, created, last_login)" | |
| 118 | + " VALUES (?,?,?,?,?,?,?)" | |
| 119 | + " ON CONFLICT(ka_id) DO UPDATE SET email=excluded.email," | |
| 120 | + " name=excluded.name, picture=excluded.picture," | |
| 121 | + " provider=excluded.provider, last_login=excluded.last_login", | |
| 122 | + (info["ka_id"], info.get("email", ""), info.get("name", ""), | |
| 123 | + info.get("picture", ""), info.get("provider", ""), now, now)) | |
| 124 | + con.commit() | |
| 125 | + row = con.execute("SELECT created, last_login FROM users WHERE ka_id=?", | |
| 126 | + (info["ka_id"],)).fetchone() | |
| 127 | + finally: | |
| 128 | + con.close() | |
| 129 | + return {"ka_id": info["ka_id"], "email": info.get("email", ""), | |
| 130 | + "name": info.get("name", ""), "picture": info.get("picture", ""), | |
| 131 | + "provider": info.get("provider", ""), | |
| 132 | + "created_at": row["created"] if row else now, | |
| 133 | + "last_login": row["last_login"] if row else now} | |
| 134 | + | |
| 135 | + | |
| 136 | +def current_user(request: Request) -> dict | None: | |
| 137 | + payload = jwt_decode(request.cookies.get(COOKIE, "")) | |
| 138 | + return payload.get("user") if payload else None | |
| 139 | + | |
| 140 | + | |
| 141 | +# -- routes -------------------------------------------------------------------- | |
| 142 | +@router.get("/ka/login") | |
| 143 | +def ka_login(next: str = "/"): | |
| 144 | + """Redirige vers le hub KA ID (groupe-ka.com) — SSO du groupe.""" | |
| 145 | + if not KA_SSO_SECRET: | |
| 146 | + return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"}, | |
| 147 | + status_code=503) | |
| 148 | + state = jwt_encode({"next": next[:200], "exp": time.time() + 600}) | |
| 149 | + params = { | |
| 150 | + "client_id": "food-ka", | |
| 151 | + "redirect_uri": f"{BASE_URL}/api/auth/ka/callback", | |
| 152 | + "state": state, | |
| 153 | + } | |
| 154 | + return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}") | |
| 155 | + | |
| 156 | + | |
| 157 | +@router.get("/ka/callback") | |
| 158 | +def ka_callback(ka_token: str = "", state: str = ""): | |
| 159 | + """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session.""" | |
| 160 | + st = jwt_decode(state) or {} | |
| 161 | + dest = st.get("next") or "/" | |
| 162 | + if not dest.startswith("/"): | |
| 163 | + dest = "/" | |
| 164 | + claims = _ka_verify(ka_token) if ka_token else None | |
| 165 | + if not st or claims is None: | |
| 166 | + return RedirectResponse("/?auth=echec") | |
| 167 | + # clé stable = KA-ID du groupe (créé par le hub, identique partout) | |
| 168 | + ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}") | |
| 169 | + user = _upsert_user({ | |
| 170 | + "ka_id": ka_id, | |
| 171 | + "email": claims.get("email", ""), | |
| 172 | + "name": claims.get("name", ""), | |
| 173 | + "picture": claims.get("picture") or "", | |
| 174 | + "provider": claims.get("provider") or "ka-id", | |
| 175 | + }) | |
| 176 | + session = jwt_encode({"user": user, "iss": "groupe-ka", | |
| 177 | + "exp": time.time() + SESSION_DAYS * 86400}) | |
| 178 | + resp = RedirectResponse(dest) | |
| 179 | + resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, | |
| 180 | + samesite="lax", secure=BASE_URL.startswith("https"), path="/") | |
| 181 | + return resp | |
| 182 | + | |
| 183 | + | |
| 184 | +@router.get("/me") | |
| 185 | +def me(request: Request): | |
| 186 | + """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si | |
| 187 | + la connexion KA ID est configurée (le frontend masque le bouton sinon). | |
| 188 | + Enrichi avec le profil du HUB Groupe KA (bio, ville, emploi, entreprise, | |
| 189 | + âge, site web, réseaux sociaux, statut, profil public) — le hub est LA | |
| 190 | + source de vérité, `profile_source` = « groupe-ka » quand il répond.""" | |
| 191 | + user = current_user(request) | |
| 192 | + if user: | |
| 193 | + user = dict(user) | |
| 194 | + hub = fetch_hub_profile(user.get("ka_id") or "") | |
| 195 | + if hub: | |
| 196 | + for k in ("name", "email", "picture"): # le hub prime | |
| 197 | + if hub.get(k): | |
| 198 | + user[k] = hub[k] | |
| 199 | + user.update({ | |
| 200 | + "bio": hub.get("bio") or "", | |
| 201 | + "city": hub.get("city") or "", | |
| 202 | + "job_title": hub.get("job_title") or "", | |
| 203 | + "company": hub.get("company") or "", | |
| 204 | + "age": hub.get("age"), | |
| 205 | + "website": hub.get("website") or "", | |
| 206 | + "socials": hub.get("socials") or {}, | |
| 207 | + "role_label": hub.get("role_label") or "", | |
| 208 | + "public": bool(hub.get("public")), | |
| 209 | + "public_url": hub.get("public_url") or "", | |
| 210 | + "profile_source": "groupe-ka", | |
| 211 | + }) | |
| 212 | + created = to_epoch(hub.get("created_at")) | |
| 213 | + if created: | |
| 214 | + user["created_at"] = created | |
| 215 | + else: | |
| 216 | + user["profile_source"] = "local" # hub injoignable ou 404 | |
| 217 | + return {"user": user, "enabled": bool(KA_SSO_SECRET)} | |
| 218 | + | |
| 219 | + | |
| 220 | +@router.post("/logout") | |
| 221 | +def logout(): | |
| 222 | + resp = JSONResponse({"ok": True}) | |
| 223 | + resp.delete_cookie(COOKIE, path="/") | |
| 224 | + return resp | |
added
foodka/hubfav.py
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# hubfav.py : favoris « Mon univers Ka » — le hub Groupe KA (groupe-ka.com) | |
| 5 | +# est le MAGASIN CENTRAL des favoris du groupe : Food·Ka ne stocke rien | |
| 6 | +# localement. Chaque ♥ est poussé au hub (POST signé HMAC, synchrone : un | |
| 7 | +# échec remonte à l'appelant) et la liste est lue au hub (GET signé, cache | |
| 8 | +# mémoire 30 s, invalidé à chaque toggle). Même secret que le SSO. | |
| 9 | +# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel). | |
| 10 | +# ----------------------------------------------------------------------------- | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +import hashlib | |
| 14 | +import hmac | |
| 15 | +import os | |
| 16 | +import threading | |
| 17 | +import time | |
| 18 | + | |
| 19 | +import requests | |
| 20 | + | |
| 21 | +CLIENT_ID = "food-ka" | |
| 22 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | |
| 23 | +CACHE_TTL = 30 # secondes — liste des favoris | |
| 24 | +TIMEOUT = 6 # secondes | |
| 25 | + | |
| 26 | +_cache: dict[str, tuple[float, list[dict]]] = {} | |
| 27 | +_lock = threading.Lock() | |
| 28 | + | |
| 29 | +# champs d'item acceptés -> longueur maximale (troncature défensive) | |
| 30 | +_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200, | |
| 31 | + "price_label": 60, "image_url": 500, "url": 500} | |
| 32 | + | |
| 33 | + | |
| 34 | +def _sig(ka_id: str, ts: int) -> str | None: | |
| 35 | + """Signature HMAC-SHA256 du hub : hex("food-ka.<ka_id>.<ts>").""" | |
| 36 | + secret = os.environ.get("KA_SSO_SECRET") | |
| 37 | + if not secret: | |
| 38 | + return None | |
| 39 | + return hmac.new(secret.encode(), | |
| 40 | + f"{CLIENT_ID}.{ka_id}.{ts}".encode(), | |
| 41 | + hashlib.sha256).hexdigest() | |
| 42 | + | |
| 43 | + | |
| 44 | +def linked(ka_id: str | None) -> bool: | |
| 45 | + """Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe).""" | |
| 46 | + return bool(ka_id) and str(ka_id).startswith("ka-") | |
| 47 | + | |
| 48 | + | |
| 49 | +def clean_item(item: dict) -> dict: | |
| 50 | + """Ne garde que les champs d'item connus, en chaînes tronquées.""" | |
| 51 | + return {k: str(item.get(k) or "")[:n] | |
| 52 | + for k, n in _FIELDS.items() if item.get(k)} | |
| 53 | + | |
| 54 | + | |
| 55 | +def hub_toggle(ka_id: str, action: str, item: dict) -> bool: | |
| 56 | + """Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s : | |
| 57 | + le hub est le magasin des favoris, l'échec doit remonter à l'appelant.""" | |
| 58 | + ts = int(time.time()) | |
| 59 | + sig = _sig(ka_id, ts) | |
| 60 | + if not sig or not linked(ka_id) or action not in ("add", "remove"): | |
| 61 | + return False | |
| 62 | + try: | |
| 63 | + r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT, | |
| 64 | + json={"client_id": CLIENT_ID, "ka_id": ka_id, | |
| 65 | + "ts": str(ts), "sig": sig, | |
| 66 | + "action": action, "item": item}) | |
| 67 | + ok = r.status_code == 200 | |
| 68 | + except Exception: | |
| 69 | + ok = False | |
| 70 | + if ok: | |
| 71 | + with _lock: | |
| 72 | + _cache.pop(ka_id, None) # la prochaine lecture reflète le toggle | |
| 73 | + return ok | |
| 74 | + | |
| 75 | + | |
| 76 | +def hub_list(ka_id: str) -> list[dict] | None: | |
| 77 | + """Favoris Food·Ka du membre, lus au hub (cache mémoire 30 s). | |
| 78 | + [] = aucun favori ; None = hub injoignable (erreur, jamais mise en cache).""" | |
| 79 | + if not linked(ka_id): | |
| 80 | + return [] # compte legacy non relié au hub | |
| 81 | + now = time.time() | |
| 82 | + with _lock: | |
| 83 | + hit = _cache.get(ka_id) | |
| 84 | + if hit and now - hit[0] < CACHE_TTL: | |
| 85 | + return hit[1] | |
| 86 | + sig = _sig(ka_id, int(now)) | |
| 87 | + if not sig: | |
| 88 | + return None | |
| 89 | + try: | |
| 90 | + r = requests.get(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT, | |
| 91 | + params={"client_id": CLIENT_ID, "ka_id": ka_id, | |
| 92 | + "ts": int(now), "sig": sig}) | |
| 93 | + if r.status_code != 200: | |
| 94 | + return None | |
| 95 | + favs = r.json().get("favorites") or [] | |
| 96 | + if not isinstance(favs, list): | |
| 97 | + return None | |
| 98 | + except Exception: | |
| 99 | + return None | |
| 100 | + favs = [f for f in favs if isinstance(f, dict)] | |
| 101 | + with _lock: | |
| 102 | + _cache[ka_id] = (now, favs) | |
| 103 | + return favs | |
added
foodka/hubprofile.py
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Food-Ka — Agrégateur de produits d'épicerie (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# hubprofile.py : profil membre lu depuis le HUB Groupe KA (groupe-ka.com) | |
| 5 | +# Le hub est LA source de vérité du profil (bio, ville, emploi, entreprise, | |
| 6 | +# site web, réseaux sociaux, photo, statut, profil public) — l'édition se | |
| 7 | +# fait sur groupe-ka.com/compte, Food·Ka ne fait qu'afficher. | |
| 8 | +# GET {hub}/api/sso/profile?client_id=food-ka&ka_id=…&ts=…&sig=… | |
| 9 | +# avec sig = HMAC-SHA256(KA_SSO_SECRET, "food-ka.<ka_id>.<ts>") en hex | |
| 10 | +# (même secret que le SSO). Cache mémoire 60 s ; None sur toute erreur | |
| 11 | +# (réseau, 401, 404 : vieux compte non relié) -> l'appelant retombe sur | |
| 12 | +# les données locales. | |
| 13 | +# ----------------------------------------------------------------------------- | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import hashlib | |
| 17 | +import hmac | |
| 18 | +import os | |
| 19 | +import threading | |
| 20 | +import time | |
| 21 | +from datetime import datetime, timezone | |
| 22 | + | |
| 23 | +import requests | |
| 24 | + | |
| 25 | +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") | |
| 26 | +CLIENT_ID = "food-ka" | |
| 27 | +CACHE_TTL = 60 # secondes | |
| 28 | +TIMEOUT = 5 # secondes | |
| 29 | + | |
| 30 | +_cache: dict[str, tuple[float, dict | None]] = {} | |
| 31 | +_lock = threading.Lock() | |
| 32 | + | |
| 33 | + | |
| 34 | +def fetch_hub_profile(ka_id: str) -> dict | None: | |
| 35 | + """Profil du membre au hub Groupe KA, ou None (inconnu ou injoignable).""" | |
| 36 | + secret = os.environ.get("KA_SSO_SECRET") | |
| 37 | + if not secret or not ka_id: | |
| 38 | + return None | |
| 39 | + now = time.time() | |
| 40 | + with _lock: | |
| 41 | + hit = _cache.get(ka_id) | |
| 42 | + if hit and now - hit[0] < CACHE_TTL: | |
| 43 | + return hit[1] | |
| 44 | + data: dict | None = None | |
| 45 | + try: | |
| 46 | + ts = int(now) | |
| 47 | + sig = hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(), | |
| 48 | + hashlib.sha256).hexdigest() | |
| 49 | + r = requests.get( | |
| 50 | + f"{KA_HUB_URL}/api/sso/profile", | |
| 51 | + params={"client_id": CLIENT_ID, "ka_id": ka_id, | |
| 52 | + "ts": ts, "sig": sig}, | |
| 53 | + timeout=TIMEOUT) | |
| 54 | + if r.status_code == 200: | |
| 55 | + data = r.json() | |
| 56 | + if not isinstance(data, dict): | |
| 57 | + return None | |
| 58 | + elif r.status_code != 404: | |
| 59 | + return None # erreur transitoire (401, 5xx…) : pas de cache | |
| 60 | + except Exception: | |
| 61 | + return None # réseau/JSON : pas de cache | |
| 62 | + with _lock: | |
| 63 | + _cache[ka_id] = (now, data) # 200 -> data ; 404 -> None (négatif) | |
| 64 | + return data | |
| 65 | + | |
| 66 | + | |
| 67 | +def to_epoch(v) -> float | None: | |
| 68 | + """created_at du hub (epoch OU chaîne ISO) -> epoch secondes, sinon None.""" | |
| 69 | + if isinstance(v, (int, float)): | |
| 70 | + return float(v) | |
| 71 | + if isinstance(v, str) and v: | |
| 72 | + try: | |
| 73 | + dt = datetime.fromisoformat(v.replace("Z", "+00:00")) | |
| 74 | + if dt.tzinfo is None: # chaîne naïve du hub = UTC | |
| 75 | + dt = dt.replace(tzinfo=timezone.utc) | |
| 76 | + return dt.timestamp() | |
| 77 | + except ValueError: | |
| 78 | + return None | |
| 79 | + return None | |
modified
foodka/web.py
+38 −2
@@ -9,12 +9,12 @@ import json | ||
| 9 | 9 | import threading |
| 10 | 10 | from pathlib import Path |
| 11 | 11 | |
| 12 | −from fastapi import BackgroundTasks, FastAPI, HTTPException, Query | |
| 12 | +from fastapi import BackgroundTasks, Body, FastAPI, HTTPException, Query, Request | |
| 13 | 13 | from fastapi.middleware.cors import CORSMiddleware |
| 14 | 14 | from fastapi.responses import FileResponse, Response |
| 15 | 15 | from fastapi.staticfiles import StaticFiles |
| 16 | 16 | |
| 17 | −from . import db, ingest | |
| 17 | +from . import auth, db, hubfav, ingest | |
| 18 | 18 | |
| 19 | 19 | ROOT = Path(__file__).resolve().parent.parent |
| 20 | 20 | SOURCES_PATH = ROOT / "data" / "sources.json" |
@@ -219,6 +219,42 @@ def trigger_sync(background: BackgroundTasks, source: str | None = None): | ||
| 219 | 219 | return {"status": "démarré", "source": source or "toutes"} |
| 220 | 220 | |
| 221 | 221 | |
| 222 | +# --- Favoris ♥ « Mon univers Ka » (magasin central : hub groupe-ka.com) ----- | |
| 223 | +@app.get("/api/favorites") | |
| 224 | +def favorites(request: Request): | |
| 225 | + """Favoris du membre connecté, lus au hub Groupe KA (aucun stockage local).""" | |
| 226 | + user = auth.current_user(request) | |
| 227 | + if not user: | |
| 228 | + raise HTTPException(401, "Connexion KA ID requise") | |
| 229 | + items = hubfav.hub_list(user.get("ka_id") or "") | |
| 230 | + if items is None: | |
| 231 | + raise HTTPException(502, "Hub Groupe KA injoignable — réessayez") | |
| 232 | + return {"ids": [i["item_id"] for i in items if i.get("item_id")], | |
| 233 | + "items": items} | |
| 234 | + | |
| 235 | + | |
| 236 | +@app.post("/api/favorites/toggle") | |
| 237 | +def toggle_favorite(request: Request, body: dict = Body(...)): | |
| 238 | + """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub | |
| 239 | + Groupe KA de façon synchrone : le hub est la seule source de vérité.""" | |
| 240 | + user = auth.current_user(request) | |
| 241 | + if not user: | |
| 242 | + raise HTTPException(401, "Connexion KA ID requise") | |
| 243 | + ka_id = user.get("ka_id") or "" | |
| 244 | + if not hubfav.linked(ka_id): | |
| 245 | + raise HTTPException(403, "Compte non relié au hub Groupe KA") | |
| 246 | + on = bool(body.get("on")) | |
| 247 | + item = hubfav.clean_item(body.get("item") or {}) | |
| 248 | + if not item.get("item_id"): | |
| 249 | + raise HTTPException(422, "item.item_id requis") | |
| 250 | + if not hubfav.hub_toggle(ka_id, "add" if on else "remove", item): | |
| 251 | + raise HTTPException(502, "Hub Groupe KA injoignable — favori non enregistré") | |
| 252 | + return {"ok": True, "on": on} | |
| 253 | + | |
| 254 | + | |
| 255 | +# --- Connexion KA ID (SSO Groupe KA) — avant le catch-all SPA --------------- | |
| 256 | +app.include_router(auth.router) | |
| 257 | + | |
| 222 | 258 | # --- Frontend React (build Vite) -------------------------------------------- |
| 223 | 259 | if FRONTEND_DIST.exists(): |
| 224 | 260 | app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") |
modified
frontend/src/App.tsx
+90 −3
@@ -5,11 +5,13 @@ | ||
| 5 | 5 | // ----------------------------------------------------------------------------- |
| 6 | 6 | import { useEffect, useState } from "react"; |
| 7 | 7 | import { NavLink, Route, Routes, useLocation } from "react-router-dom"; |
| 8 | −import { fetchFacets, fetchSources, fetchStats, fmtPrice, registerSourceNames, sourceName } from "./api"; | |
| 8 | +import { Me, fetchAuth, fetchFacets, fetchSources, fetchStats, fmtPrice, logout, registerSourceNames, sourceName } from "./api"; | |
| 9 | 9 | import CookieConsent from "./components/CookieConsent"; |
| 10 | +import { FavProvider } from "./favorites"; | |
| 10 | 11 | import Home from "./pages/Home"; |
| 11 | 12 | import ProductPage from "./pages/Product"; |
| 12 | 13 | import PrivacyPage from "./pages/Privacy"; |
| 14 | +import ProfilPage from "./pages/Profil"; | |
| 13 | 15 | import SourcesPage from "./pages/Sources"; |
| 14 | 16 | import StatsPage from "./pages/Stats"; |
| 15 | 17 | |
@@ -53,9 +55,92 @@ const NAV_LINKS = [ | ||
| 53 | 55 | { to: "/aubaines", label: "Aubaines", icon: "🔥", end: false }, |
| 54 | 56 | { to: "/sources", label: "Sources", icon: "🗂", end: false }, |
| 55 | 57 | { to: "/stats", label: "Stats", icon: "📊", end: false }, |
| 58 | + { to: "/profil", label: "Mon compte", icon: "👤", end: false }, | |
| 56 | 59 | { to: "/confidentialite", label: "Confidentialité", icon: "🔒", end: false }, |
| 57 | 60 | ]; |
| 58 | 61 | |
| 62 | +/** Bouton « Connexion » ou avatar + menu compte (KA ID — SSO Groupe KA) */ | |
| 63 | +function AccountMenu() { | |
| 64 | + const [me, setMe] = useState<Me | null>(null); | |
| 65 | + const [enabled, setEnabled] = useState(false); | |
| 66 | + const [menuOpen, setMenuOpen] = useState(false); | |
| 67 | + const location = useLocation(); | |
| 68 | + | |
| 69 | + useEffect(() => { | |
| 70 | + fetchAuth().then(({ user, enabled }) => { setMe(user); setEnabled(enabled); }); | |
| 71 | + }, []); | |
| 72 | + useEffect(() => { setMenuOpen(false); }, [location]); | |
| 73 | + | |
| 74 | + if (!enabled) return null; | |
| 75 | + if (!me) { | |
| 76 | + const next = encodeURIComponent(location.pathname + location.search); | |
| 77 | + return ( | |
| 78 | + <a className="login-btn" href={`/api/auth/ka/login?next=${next}`}> | |
| 79 | + <span className="login-ka" aria-hidden="true">KA</span> | |
| 80 | + Connexion | |
| 81 | + </a> | |
| 82 | + ); | |
| 83 | + } | |
| 84 | + return ( | |
| 85 | + <div className="account"> | |
| 86 | + <button | |
| 87 | + className="account-btn" | |
| 88 | + onClick={() => setMenuOpen(!menuOpen)} | |
| 89 | + aria-expanded={menuOpen} | |
| 90 | + aria-label={`Compte : ${me.name || me.email}`} | |
| 91 | + > | |
| 92 | + {me.picture | |
| 93 | + ? <img src={me.picture} alt="" referrerPolicy="no-referrer" /> | |
| 94 | + : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>} | |
| 95 | + </button> | |
| 96 | + {menuOpen && ( | |
| 97 | + <> | |
| 98 | + <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" /> | |
| 99 | + <div className="account-menu" role="menu"> | |
| 100 | + <div className="account-id"> | |
| 101 | + <b>{me.name || "Mon compte"}</b> | |
| 102 | + <span>{me.email}</span> | |
| 103 | + <span className="account-kaid">{me.ka_id}</span> | |
| 104 | + <span className="account-note"> | |
| 105 | + Compte Groupe KA — le même KA-ID sur toutes les plateformes | |
| 106 | + </span> | |
| 107 | + </div> | |
| 108 | + <NavLink | |
| 109 | + to="/profil" | |
| 110 | + role="menuitem" | |
| 111 | + className="account-link" | |
| 112 | + onClick={() => setMenuOpen(false)} | |
| 113 | + > | |
| 114 | + Mon profil | |
| 115 | + </NavLink> | |
| 116 | + <a | |
| 117 | + href="https://www.groupe-ka.com/compte" | |
| 118 | + target="_blank" | |
| 119 | + rel="noopener noreferrer" | |
| 120 | + role="menuitem" | |
| 121 | + className="account-link" | |
| 122 | + onClick={() => setMenuOpen(false)} | |
| 123 | + > | |
| 124 | + Mes favoris — Mon univers Ka ↗ | |
| 125 | + </a> | |
| 126 | + <button | |
| 127 | + role="menuitem" | |
| 128 | + onClick={async () => { | |
| 129 | + await logout(); | |
| 130 | + setMenuOpen(false); | |
| 131 | + setMe(null); | |
| 132 | + window.location.assign("/"); | |
| 133 | + }} | |
| 134 | + > | |
| 135 | + Se déconnecter | |
| 136 | + </button> | |
| 137 | + </div> | |
| 138 | + </> | |
| 139 | + )} | |
| 140 | + </div> | |
| 141 | + ); | |
| 142 | +} | |
| 143 | + | |
| 59 | 144 | function Header() { |
| 60 | 145 | const [open, setOpen] = useState(false); |
| 61 | 146 | const location = useLocation(); |
@@ -98,6 +183,7 @@ function Header() { | ||
| 98 | 183 | Stats |
| 99 | 184 | </NavLink> |
| 100 | 185 | </nav> |
| 186 | + <AccountMenu /> | |
| 101 | 187 | <button |
| 102 | 188 | className={`menu-btn ${open ? "open" : ""}`} |
| 103 | 189 | aria-expanded={open} |
@@ -171,7 +257,7 @@ function Footer() { | ||
| 171 | 257 | |
| 172 | 258 | export default function App() { |
| 173 | 259 | return ( |
| 174 | − <> | |
| 260 | + <FavProvider> | |
| 175 | 261 | <Header /> |
| 176 | 262 | <main> |
| 177 | 263 | <Routes> |
@@ -180,6 +266,7 @@ export default function App() { | ||
| 180 | 266 | <Route path="/produit/:uid" element={<ProductPage />} /> |
| 181 | 267 | <Route path="/stats" element={<StatsPage />} /> |
| 182 | 268 | <Route path="/sources" element={<SourcesPage />} /> |
| 269 | + <Route path="/profil" element={<ProfilPage />} /> | |
| 183 | 270 | <Route path="/confidentialite" element={<PrivacyPage />} /> |
| 184 | 271 | <Route |
| 185 | 272 | path="*" |
@@ -195,6 +282,6 @@ export default function App() { | ||
| 195 | 282 | </main> |
| 196 | 283 | <Footer /> |
| 197 | 284 | <CookieConsent /> |
| 198 | − </> | |
| 285 | + </FavProvider> | |
| 199 | 286 | ); |
| 200 | 287 | } |
modified
frontend/src/api.ts
+49 −0
@@ -134,6 +134,55 @@ export interface DetailedStats { | ||
| 134 | 134 | price_distribution: { range: string; n: number }[]; |
| 135 | 135 | } |
| 136 | 136 | |
| 137 | +// --- Compte (connexion KA ID — SSO Groupe KA) --------------------------------- | |
| 138 | +export interface Socials { | |
| 139 | + instagram?: string; | |
| 140 | + facebook?: string; | |
| 141 | + x?: string; | |
| 142 | + linkedin?: string; | |
| 143 | + tiktok?: string; | |
| 144 | + youtube?: string; | |
| 145 | +} | |
| 146 | + | |
| 147 | +export interface Me { | |
| 148 | + ka_id: string; // identifiant membre Groupe KA (« ka-0123456789 ») | |
| 149 | + email: string; | |
| 150 | + name: string; | |
| 151 | + picture: string; | |
| 152 | + provider: string; // fournisseur d'identité côté hub (google, email…) | |
| 153 | + created_at: number | null; | |
| 154 | + last_login: number | null; | |
| 155 | + // — profil géré au HUB Groupe KA (source de vérité : groupe-ka.com/compte) — | |
| 156 | + bio?: string; | |
| 157 | + city?: string; | |
| 158 | + job_title?: string; | |
| 159 | + company?: string; | |
| 160 | + age?: number | null; | |
| 161 | + website?: string; | |
| 162 | + socials?: Socials; | |
| 163 | + role_label?: string; // statut du membre (badge sur la carte) | |
| 164 | + public?: boolean; | |
| 165 | + public_url?: string; | |
| 166 | + profile_source?: string; // « groupe-ka » si le hub a répondu, sinon « local » | |
| 167 | +} | |
| 168 | + | |
| 169 | +/** Profil de l'utilisateur connecté (null si déconnecté) + SSO configuré ou non. */ | |
| 170 | +export async function fetchAuth(): Promise<{ user: Me | null; enabled: boolean }> { | |
| 171 | + try { | |
| 172 | + const res = await fetch("/api/auth/me", { credentials: "same-origin" }); | |
| 173 | + if (!res.ok) throw new Error(`API ${res.status}`); | |
| 174 | + return (await res.json()) as { user: Me | null; enabled: boolean }; | |
| 175 | + } catch { | |
| 176 | + return { user: null, enabled: false }; | |
| 177 | + } | |
| 178 | +} | |
| 179 | + | |
| 180 | +export async function logout(): Promise<void> { | |
| 181 | + try { | |
| 182 | + await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" }); | |
| 183 | + } catch { /* déconnexion silencieuse */ } | |
| 184 | +} | |
| 185 | + | |
| 137 | 186 | // --- Noms d'affichage des bannières ------------------------------------------ |
| 138 | 187 | const SOURCE_NAMES: Record<string, string> = { |
| 139 | 188 | metro: "Metro", |
modified
frontend/src/components/ProductCard.tsx
+2 −0
@@ -7,6 +7,7 @@ | ||
| 7 | 7 | // ----------------------------------------------------------------------------- |
| 8 | 8 | import { Link } from "react-router-dom"; |
| 9 | 9 | import { Product, discountPct, fmtPrice } from "../api"; |
| 10 | +import { FavButton } from "../favorites"; | |
| 10 | 11 | import SourceLogo from "./SourceLogo"; |
| 11 | 12 | |
| 12 | 13 | export default function ProductCard({ p }: { p: Product }) { |
@@ -24,6 +25,7 @@ export default function ProductCard({ p }: { p: Product }) { | ||
| 24 | 25 | <span className="badge sale">{pct != null ? `−${pct} %` : "En solde"}</span> |
| 25 | 26 | )} |
| 26 | 27 | {p.in_stock === false && <span className="badge right">Rupture</span>} |
| 28 | + <FavButton p={p} /> | |
| 27 | 29 | </div> |
| 28 | 30 | <div className="card-body"> |
| 29 | 31 | <div className="card-price"> |
added
frontend/src/favorites.tsx
+133 −0
@@ -0,0 +1,133 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// favorites.tsx : favoris ♥ « Mon univers Ka » — le hub Groupe KA est le | |
| 5 | +// magasin central des favoris du groupe (aucun stockage navigateur) : | |
| 6 | +// lecture/écriture via l'API locale /api/favorites qui relaie au hub. | |
| 7 | +// FavProvider charge la liste une fois ; FavButton = cœur sur les cartes | |
| 8 | +// produit et la fiche. Non connecté → redirection vers la connexion KA ID | |
| 9 | +// avec retour à la page courante. | |
| 10 | +// ----------------------------------------------------------------------------- | |
| 11 | +import { | |
| 12 | + ReactNode, createContext, useCallback, useContext, | |
| 13 | + useEffect, useMemo, useState, | |
| 14 | +} from "react"; | |
| 15 | +import { Product, fmtPrice, sourceName } from "./api"; | |
| 16 | + | |
| 17 | +/** Item de favori tel que poussé au hub Groupe KA. */ | |
| 18 | +export interface FavItem { | |
| 19 | + item_id: string; | |
| 20 | + title: string; | |
| 21 | + subtitle?: string; | |
| 22 | + price_label?: string; | |
| 23 | + image_url?: string; | |
| 24 | + url?: string; | |
| 25 | +} | |
| 26 | + | |
| 27 | +interface FavState { | |
| 28 | + ready: boolean; // premier chargement terminé | |
| 29 | + connected: boolean; // session KA ID active | |
| 30 | + ids: Set<string>; // uid des produits en favoris | |
| 31 | + items: FavItem[]; | |
| 32 | + toggle: (item: FavItem) => void; | |
| 33 | +} | |
| 34 | + | |
| 35 | +const FavContext = createContext<FavState>({ | |
| 36 | + ready: false, connected: false, ids: new Set(), items: [], toggle: () => {}, | |
| 37 | +}); | |
| 38 | + | |
| 39 | +/** Produit -> item de favori du hub (titre, bannière · format, prix, image). */ | |
| 40 | +export function productFavItem(p: Product): FavItem { | |
| 41 | + let price = fmtPrice(p.price, p.price_label); | |
| 42 | + if (p.on_sale && p.regular_price != null) | |
| 43 | + price += ` (rég. ${fmtPrice(p.regular_price)})`; | |
| 44 | + return { | |
| 45 | + item_id: p.uid, | |
| 46 | + title: p.name || "Produit", | |
| 47 | + subtitle: [sourceName(p.source), p.size_label || p.category] | |
| 48 | + .filter(Boolean).join(" · "), | |
| 49 | + price_label: price, | |
| 50 | + image_url: p.images && p.images.length > 0 ? p.images[0] : "", | |
| 51 | + url: `https://www.food-ka.com/produit/${encodeURIComponent(p.uid)}`, | |
| 52 | + }; | |
| 53 | +} | |
| 54 | + | |
| 55 | +export function FavProvider({ children }: { children: ReactNode }) { | |
| 56 | + const [ready, setReady] = useState(false); | |
| 57 | + const [connected, setConnected] = useState(false); | |
| 58 | + const [items, setItems] = useState<FavItem[]>([]); | |
| 59 | + | |
| 60 | + useEffect(() => { | |
| 61 | + fetch("/api/favorites", { credentials: "same-origin" }) | |
| 62 | + .then(async (res) => { | |
| 63 | + if (res.status === 401) return; // pas connecté : cœurs vides | |
| 64 | + if (!res.ok) throw new Error(`API ${res.status}`); | |
| 65 | + const data = (await res.json()) as { items?: FavItem[] }; | |
| 66 | + setConnected(true); | |
| 67 | + setItems(data.items ?? []); | |
| 68 | + }) | |
| 69 | + .catch(() => {}) // hub muet : cœurs vides | |
| 70 | + .finally(() => setReady(true)); | |
| 71 | + }, []); | |
| 72 | + | |
| 73 | + const toggle = useCallback((item: FavItem) => { | |
| 74 | + if (!connected) { | |
| 75 | + // non connecté : passage par la connexion KA ID, retour à la page | |
| 76 | + const next = encodeURIComponent( | |
| 77 | + window.location.pathname + window.location.search); | |
| 78 | + window.location.assign(`/api/auth/ka/login?next=${next}`); | |
| 79 | + return; | |
| 80 | + } | |
| 81 | + const prev = items; | |
| 82 | + const on = !prev.some((i) => i.item_id === item.item_id); | |
| 83 | + // optimiste : le hub confirme (sinon on rétablit) | |
| 84 | + setItems(on ? [...prev, item] | |
| 85 | + : prev.filter((i) => i.item_id !== item.item_id)); | |
| 86 | + fetch("/api/favorites/toggle", { | |
| 87 | + method: "POST", | |
| 88 | + credentials: "same-origin", | |
| 89 | + headers: { "Content-Type": "application/json" }, | |
| 90 | + body: JSON.stringify({ on, item }), | |
| 91 | + }) | |
| 92 | + .then((res) => { if (!res.ok) throw new Error(`API ${res.status}`); }) | |
| 93 | + .catch(() => setItems(prev)); | |
| 94 | + }, [connected, items]); | |
| 95 | + | |
| 96 | + const ids = useMemo( | |
| 97 | + () => new Set(items.map((i) => i.item_id)), [items]); | |
| 98 | + | |
| 99 | + return ( | |
| 100 | + <FavContext.Provider value={{ ready, connected, ids, items, toggle }}> | |
| 101 | + {children} | |
| 102 | + </FavContext.Provider> | |
| 103 | + ); | |
| 104 | +} | |
| 105 | + | |
| 106 | +export const useFav = () => useContext(FavContext); | |
| 107 | + | |
| 108 | +/** Cœur ♥ — cartes produit (par défaut) et fiche produit (`big`). */ | |
| 109 | +export function FavButton({ p, big = false }: { p: Product; big?: boolean }) { | |
| 110 | + const { ids, toggle } = useFav(); | |
| 111 | + const on = ids.has(p.uid); | |
| 112 | + const label = on | |
| 113 | + ? "Retirer de mes favoris (Mon univers Ka)" | |
| 114 | + : "Ajouter à mes favoris (Mon univers Ka)"; | |
| 115 | + return ( | |
| 116 | + <button | |
| 117 | + type="button" | |
| 118 | + className={`fav-btn${on ? " on" : ""}${big ? " big" : ""}`} | |
| 119 | + aria-label={label} | |
| 120 | + aria-pressed={on} | |
| 121 | + title={label} | |
| 122 | + onClick={(e) => { | |
| 123 | + e.preventDefault(); // la carte entière est un lien | |
| 124 | + e.stopPropagation(); | |
| 125 | + toggle(productFavItem(p)); | |
| 126 | + }} | |
| 127 | + > | |
| 128 | + <svg viewBox="0 0 24 24" aria-hidden="true"> | |
| 129 | + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> | |
| 130 | + </svg> | |
| 131 | + </button> | |
| 132 | + ); | |
| 133 | +} | |
modified
frontend/src/pages/Product.tsx
+9 −5
@@ -14,6 +14,7 @@ import { | ||
| 14 | 14 | registerSourceNames, sourceName, |
| 15 | 15 | } from "../api"; |
| 16 | 16 | import SourceLogo from "../components/SourceLogo"; |
| 17 | +import { FavButton } from "../favorites"; | |
| 17 | 18 | |
| 18 | 19 | const NBSP = " "; |
| 19 | 20 | |
@@ -240,11 +241,14 @@ export default function ProductPage() { | ||
| 240 | 241 | {/* ------- colonne droite (desktop) : prix, comparaison --------------- */} |
| 241 | 242 | <div className="f-col"> |
| 242 | 243 | <section className="f-bloc f-hero"> |
| 243 | − <div className="price"> | |
| 244 | − {fmtPrice(p.price, p.price_label)} | |
| 245 | − {p.on_sale && p.regular_price != null && ( | |
| 246 | − <s className="price-old big">{fmtPrice(p.regular_price)}</s> | |
| 247 | − )} | |
| 244 | + <div className="price-fav"> | |
| 245 | + <div className="price"> | |
| 246 | + {fmtPrice(p.price, p.price_label)} | |
| 247 | + {p.on_sale && p.regular_price != null && ( | |
| 248 | + <s className="price-old big">{fmtPrice(p.regular_price)}</s> | |
| 249 | + )} | |
| 250 | + </div> | |
| 251 | + <FavButton p={p} big /> | |
| 248 | 252 | </div> |
| 249 | 253 | {p.on_sale && ( |
| 250 | 254 | <div className="deal-badge deal-good"> |
added
frontend/src/pages/Profil.tsx
+228 −0
@@ -0,0 +1,228 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Food-Ka — Agrégateur de produits d'épicerie (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// pages/Profil.tsx : profil utilisateur — carte de membre Groupe KA (KA-ID), | |
| 5 | +// informations du compte, actions (déconnexion, confidentialité) | |
| 6 | +// ----------------------------------------------------------------------------- | |
| 7 | +import { useEffect, useState } from "react"; | |
| 8 | +import { Link, useNavigate } from "react-router-dom"; | |
| 9 | +import { Me, Socials, fetchAuth, logout } from "../api"; | |
| 10 | + | |
| 11 | +const fmtEpoch = (ts: number | null | undefined): string => { | |
| 12 | + if (!ts) return "—"; | |
| 13 | + return new Date(ts * 1000).toLocaleDateString("fr-CA", { | |
| 14 | + day: "numeric", month: "long", year: "numeric", | |
| 15 | + }).replace(/^1 /, "1ᵉʳ "); | |
| 16 | +}; | |
| 17 | + | |
| 18 | +const providerLabel = (p: string): string => | |
| 19 | + p === "google" ? "KA ID — via Google" | |
| 20 | + : p === "email" || p === "password" ? "KA ID — courriel et mot de passe" | |
| 21 | + : "KA ID (groupe-ka.com)"; | |
| 22 | + | |
| 23 | +// --- Profil Groupe KA (géré au hub — lecture seule ici) ----------------------- | |
| 24 | +const SOCIAL_BASE: Record<string, string> = { | |
| 25 | + instagram: "https://www.instagram.com/", | |
| 26 | + facebook: "https://www.facebook.com/", | |
| 27 | + x: "https://x.com/", | |
| 28 | + linkedin: "https://www.linkedin.com/in/", | |
| 29 | + tiktok: "https://www.tiktok.com/@", | |
| 30 | + youtube: "https://www.youtube.com/@", | |
| 31 | +}; | |
| 32 | + | |
| 33 | +/** « @pseudo » ou « pseudo » -> URL complète de la plateforme ; URL laissée telle quelle */ | |
| 34 | +function socialUrl(key: string, value: string): string { | |
| 35 | + const v = value.trim(); | |
| 36 | + if (/^https?:\/\//i.test(v)) return v; | |
| 37 | + if (key === "website") return `https://${v}`; | |
| 38 | + return (SOCIAL_BASE[key] ?? "https://") + v.replace(/^@/, ""); | |
| 39 | +} | |
| 40 | + | |
| 41 | +const SOCIAL_FIELDS: { key: keyof Socials; label: string }[] = [ | |
| 42 | + { key: "instagram", label: "Instagram" }, | |
| 43 | + { key: "facebook", label: "Facebook" }, | |
| 44 | + { key: "x", label: "X (Twitter)" }, | |
| 45 | + { key: "linkedin", label: "LinkedIn" }, | |
| 46 | + { key: "tiktok", label: "TikTok" }, | |
| 47 | + { key: "youtube", label: "YouTube" }, | |
| 48 | +]; | |
| 49 | + | |
| 50 | +/** Profil géré au HUB Groupe KA — LECTURE SEULE (l'édition se fait sur | |
| 51 | + * groupe-ka.com/compte, une seule saisie pour les huit plateformes). */ | |
| 52 | +function HubProfileSection({ me }: { me: Me }) { | |
| 53 | + const socialLinks = SOCIAL_FIELDS.filter((s) => (me.socials?.[s.key] ?? "").trim()); | |
| 54 | + return ( | |
| 55 | + <section className="hub-profile"> | |
| 56 | + <h3>Mon profil Groupe KA</h3> | |
| 57 | + {me.bio && <p className="hub-bio">{me.bio}</p>} | |
| 58 | + <div className="pub-meta"> | |
| 59 | + {(me.job_title || me.company) && ( | |
| 60 | + <span className="stat-chip"> | |
| 61 | + {[me.job_title, me.company].filter(Boolean).join(" · ")} | |
| 62 | + </span> | |
| 63 | + )} | |
| 64 | + {me.city && <span className="stat-chip">{me.city}</span>} | |
| 65 | + {me.age != null && <span className="stat-chip">{me.age} ans</span>} | |
| 66 | + {me.website && ( | |
| 67 | + <a className="stat-chip" href={socialUrl("website", me.website)} | |
| 68 | + target="_blank" rel="noopener noreferrer">Site web ↗</a> | |
| 69 | + )} | |
| 70 | + {socialLinks.map((s) => ( | |
| 71 | + <a key={s.key} className="stat-chip" | |
| 72 | + href={socialUrl(s.key, me.socials![s.key]!)} | |
| 73 | + target="_blank" rel="noopener noreferrer" title={s.label}> | |
| 74 | + {s.label} | |
| 75 | + </a> | |
| 76 | + ))} | |
| 77 | + </div> | |
| 78 | + <div> | |
| 79 | + <a className="btn btn-primary" | |
| 80 | + href="https://www.groupe-ka.com/compte" | |
| 81 | + target="_blank" rel="noopener noreferrer"> | |
| 82 | + Modifier mon profil sur groupe-ka.com ↗ | |
| 83 | + </a> | |
| 84 | + </div> | |
| 85 | + <p className="pe-hint"> | |
| 86 | + Votre profil est géré au niveau du groupe : une seule saisie, visible | |
| 87 | + sur les huit plateformes du groupe. | |
| 88 | + </p> | |
| 89 | + </section> | |
| 90 | + ); | |
| 91 | +} | |
| 92 | + | |
| 93 | +export default function ProfilPage() { | |
| 94 | + const [me, setMe] = useState<Me | null | undefined>(undefined); // undefined = chargement | |
| 95 | + const [copied, setCopied] = useState(false); | |
| 96 | + const nav = useNavigate(); | |
| 97 | + | |
| 98 | + useEffect(() => { | |
| 99 | + fetchAuth().then(({ user }) => setMe(user)); | |
| 100 | + }, []); | |
| 101 | + | |
| 102 | + const copyKaId = async () => { | |
| 103 | + if (!me?.ka_id) return; | |
| 104 | + try { | |
| 105 | + await navigator.clipboard.writeText(me.ka_id); | |
| 106 | + setCopied(true); | |
| 107 | + setTimeout(() => setCopied(false), 1800); | |
| 108 | + } catch { /* presse-papiers indisponible : tant pis */ } | |
| 109 | + }; | |
| 110 | + | |
| 111 | + if (me === undefined) { | |
| 112 | + return <div className="container profil"><div className="notice">Chargement…</div></div>; | |
| 113 | + } | |
| 114 | + | |
| 115 | + if (me === null) { | |
| 116 | + return ( | |
| 117 | + <div className="container profil"> | |
| 118 | + <span className="kicker">Mon compte</span> | |
| 119 | + <h1>Connectez-vous pour <span className="hl">votre profil</span>.</h1> | |
| 120 | + <p className="lede"> | |
| 121 | + Connectez-vous avec votre <b>KA ID</b> — l'identifiant de membre | |
| 122 | + unique du Groupe KA, valide sur toutes les plateformes du groupe. | |
| 123 | + </p> | |
| 124 | + <a className="btn btn-primary" href="/api/auth/ka/login?next=/profil"> | |
| 125 | + Se connecter avec KA ID | |
| 126 | + </a> | |
| 127 | + </div> | |
| 128 | + ); | |
| 129 | + } | |
| 130 | + | |
| 131 | + return ( | |
| 132 | + <div className="container profil"> | |
| 133 | + <span className="kicker">Mon compte</span> | |
| 134 | + <h1> | |
| 135 | + {me.name ? <>Salut, <span className="hl">{me.name.split(" ")[0]}</span>.</> | |
| 136 | + : <>Votre <span className="hl">profil</span>.</>} | |
| 137 | + </h1> | |
| 138 | + | |
| 139 | + {/* ——— Carte de membre Groupe KA ——— */} | |
| 140 | + <div className="pc" role="img" aria-label={`Carte de membre ${me.ka_id}`}> | |
| 141 | + <div className="pc-watermark" aria-hidden="true">KA</div> | |
| 142 | + <div className="pc-head"> | |
| 143 | + <span className="pc-brand">Groupe <span className="pc-ka">KA</span></span> | |
| 144 | + <span className="pc-label">Carte de membre · Groupe KA</span> | |
| 145 | + </div> | |
| 146 | + <div className="pc-id-block"> | |
| 147 | + <span className="pc-id-label">KA-ID</span> | |
| 148 | + <span className="pc-id">{me.ka_id}</span> | |
| 149 | + </div> | |
| 150 | + <div className="pc-foot"> | |
| 151 | + <div className="pc-holder"> | |
| 152 | + <span className="pc-holder-name">{me.name || me.email}</span> | |
| 153 | + {me.role_label && ( | |
| 154 | + <span className="pc-role-badge">{me.role_label}</span> | |
| 155 | + )} | |
| 156 | + <span className="pc-holder-since">Membre depuis le {fmtEpoch(me.created_at)}</span> | |
| 157 | + </div> | |
| 158 | + {me.picture && ( | |
| 159 | + <img className="pc-avatar" src={me.picture} alt="" referrerPolicy="no-referrer" /> | |
| 160 | + )} | |
| 161 | + </div> | |
| 162 | + <div className="pc-strip" aria-hidden="true"> | |
| 163 | + {Array.from({ length: 28 }).map((_, i) => <i key={i} />)} | |
| 164 | + </div> | |
| 165 | + </div> | |
| 166 | + | |
| 167 | + <button className={`btn btn-ghost pc-copy ${copied ? "ok" : ""}`} onClick={copyKaId}> | |
| 168 | + {copied ? "✓ Copié" : "Copier mon KA-ID"} | |
| 169 | + </button> | |
| 170 | + | |
| 171 | + {/* ——— Informations ——— */} | |
| 172 | + <section className="profil-grid"> | |
| 173 | + <div className="pg-item"> | |
| 174 | + <span className="pg-label">Nom</span> | |
| 175 | + <span className="pg-value">{me.name || "—"}</span> | |
| 176 | + </div> | |
| 177 | + <div className="pg-item"> | |
| 178 | + <span className="pg-label">Courriel</span> | |
| 179 | + <span className="pg-value">{me.email || "—"}</span> | |
| 180 | + </div> | |
| 181 | + <div className="pg-item"> | |
| 182 | + <span className="pg-label">Identifiant membre</span> | |
| 183 | + <span className="pg-value mono">{me.ka_id}</span> | |
| 184 | + </div> | |
| 185 | + <div className="pg-item"> | |
| 186 | + <span className="pg-label">Connexion</span> | |
| 187 | + <span className="pg-value">{providerLabel(me.provider)}</span> | |
| 188 | + </div> | |
| 189 | + <div className="pg-item"> | |
| 190 | + <span className="pg-label">Membre depuis</span> | |
| 191 | + <span className="pg-value">{fmtEpoch(me.created_at)}</span> | |
| 192 | + </div> | |
| 193 | + <div className="pg-item"> | |
| 194 | + <span className="pg-label">Dernière connexion</span> | |
| 195 | + <span className="pg-value">{fmtEpoch(me.last_login)}</span> | |
| 196 | + </div> | |
| 197 | + </section> | |
| 198 | + | |
| 199 | + {me.profile_source === "groupe-ka" && <HubProfileSection me={me} />} | |
| 200 | + | |
| 201 | + <p className="profil-note"> | |
| 202 | + Votre <b>KA-ID</b> est votre identifiant unique dans l'écosystème{" "} | |
| 203 | + <a href="https://www.groupe-ka.com" target="_blank" rel="noopener noreferrer"> | |
| 204 | + Groupe KA | |
| 205 | + </a>{" "} | |
| 206 | + — le même sur toutes les plateformes du groupe. Food·Ka ne conserve que | |
| 207 | + les informations de ce profil ; rien d'autre, et jamais revendues. | |
| 208 | + </p> | |
| 209 | + | |
| 210 | + {/* ——— Actions ——— */} | |
| 211 | + <div className="profil-actions"> | |
| 212 | + <button | |
| 213 | + className="btn btn-ghost" | |
| 214 | + onClick={async () => { await logout(); nav("/"); window.location.reload(); }} | |
| 215 | + > | |
| 216 | + Se déconnecter | |
| 217 | + </button> | |
| 218 | + <button | |
| 219 | + className="btn btn-ghost" | |
| 220 | + onClick={() => window.dispatchEvent(new Event("foodka:openConsent"))} | |
| 221 | + > | |
| 222 | + Gérer mes témoins | |
| 223 | + </button> | |
| 224 | + <Link className="btn btn-ghost" to="/confidentialite">Confidentialité</Link> | |
| 225 | + </div> | |
| 226 | + </div> | |
| 227 | + ); | |
| 228 | +} | |
modified
frontend/src/styles.css
+154 −0
@@ -988,3 +988,157 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */ | ||
| 988 | 988 | /* ---- bouton rapport PDF (page Stats) ---- */ |
| 989 | 989 | .stats-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; } |
| 990 | 990 | .btn-pdf { text-decoration: none; white-space: nowrap; } |
| 991 | + | |
| 992 | +/* ============ Favoris ♥ « Mon univers Ka » (magasin central au hub) ========= */ | |
| 993 | +.fav-btn { | |
| 994 | + position: absolute; right: 10px; bottom: 10px; z-index: 2; | |
| 995 | + width: 34px; height: 34px; padding: 0; | |
| 996 | + display: inline-flex; align-items: center; justify-content: center; | |
| 997 | + border: 1.5px solid var(--ink); border-radius: 999px; cursor: pointer; | |
| 998 | + background: rgba(255, 255, 255, 0.95); color: var(--ink); | |
| 999 | + transition: transform 0.12s ease, background 0.12s ease; | |
| 1000 | +} | |
| 1001 | +.fav-btn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; } | |
| 1002 | +.fav-btn:hover { transform: scale(1.08); background: var(--lime-soft); } | |
| 1003 | +.fav-btn.on { background: var(--tomato); border-color: var(--tomato-deep); color: #fff; } | |
| 1004 | +.fav-btn.on svg { fill: currentColor; } | |
| 1005 | +.fav-btn.big { position: static; flex-shrink: 0; width: 42px; height: 42px; } | |
| 1006 | +.fav-btn.big svg { width: 21px; height: 21px; } | |
| 1007 | +/* fiche produit : le cœur à droite du prix */ | |
| 1008 | +.price-fav { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } | |
| 1009 | + | |
| 1010 | +/* ================= Compte (connexion KA ID — SSO Groupe KA) ================= */ | |
| 1011 | +.login-btn { | |
| 1012 | + display: inline-flex; align-items: center; gap: 8px; | |
| 1013 | + padding: 8px 15px; margin-left: 10px; min-height: 38px; | |
| 1014 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 1015 | + border-radius: 999px; font-weight: 600; font-size: 13.5px; color: var(--ink); | |
| 1016 | + transition: all 0.12s ease; white-space: nowrap; text-decoration: none; | |
| 1017 | +} | |
| 1018 | +.login-btn:hover { transform: translate(-1px, -1px); box-shadow: 3px 3px 0 var(--ink); background: var(--lime-soft); } | |
| 1019 | +.login-ka { | |
| 1020 | + display: inline-flex; align-items: center; justify-content: center; | |
| 1021 | + width: 18px; height: 16px; border-radius: 4px; | |
| 1022 | + background: var(--green-deep); color: var(--lime); | |
| 1023 | + font: 700 9px/1 var(--font-display); letter-spacing: -0.02em; | |
| 1024 | + transform: rotate(-2deg); | |
| 1025 | +} | |
| 1026 | +.account { position: relative; margin-left: 10px; } | |
| 1027 | +.account-btn { | |
| 1028 | + width: 38px; height: 38px; padding: 0; border: 2px solid var(--ink); | |
| 1029 | + border-radius: 999px; overflow: hidden; cursor: pointer; | |
| 1030 | + background: var(--lime); display: flex; align-items: center; justify-content: center; | |
| 1031 | +} | |
| 1032 | +.account-btn img { width: 100%; height: 100%; object-fit: cover; } | |
| 1033 | +.account-initial { font-family: var(--font-display); font-weight: 700; font-size: 16px; color: var(--ink); } | |
| 1034 | +.account-backdrop { position: fixed; inset: 0; z-index: 90; } | |
| 1035 | +.account-menu { | |
| 1036 | + position: absolute; right: 0; top: calc(100% + 10px); z-index: 91; | |
| 1037 | + min-width: 250px; max-width: 320px; background: var(--surface); | |
| 1038 | + border: 2px solid var(--ink); border-radius: var(--r-card); | |
| 1039 | + box-shadow: var(--shadow-off); overflow: hidden; | |
| 1040 | +} | |
| 1041 | +.account-id { display: flex; flex-direction: column; gap: 2px; padding: 12px 14px; border-bottom: 1.5px solid var(--line); } | |
| 1042 | +.account-id b { font-size: 14px; } | |
| 1043 | +.account-id span { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); word-break: break-all; } | |
| 1044 | +.account-kaid { color: var(--green) !important; letter-spacing: 0.08em; } | |
| 1045 | +.account-note { | |
| 1046 | + font-family: inherit !important; font-size: 10.5px !important; | |
| 1047 | + color: var(--ink-3); margin-top: 4px; line-height: 1.35; | |
| 1048 | +} | |
| 1049 | +.account-link { | |
| 1050 | + display: block; padding: 11px 14px; font-weight: 600; font-size: 13.5px; | |
| 1051 | + color: var(--ink); text-decoration: none; border-bottom: 1.5px solid var(--line); | |
| 1052 | +} | |
| 1053 | +.account-link:hover { background: var(--lime-soft); } | |
| 1054 | +.account-menu button { | |
| 1055 | + width: 100%; text-align: left; padding: 11px 14px; border: 0; cursor: pointer; | |
| 1056 | + background: transparent; font-weight: 600; font-size: 13.5px; color: var(--danger); | |
| 1057 | +} | |
| 1058 | +.account-menu button:hover { background: var(--lime-soft); color: var(--ink); } | |
| 1059 | +@media (max-width: 640px) { .login-btn { padding: 8px 12px; font-size: 12.5px; } } | |
| 1060 | +/* mobile : le nav est masqué — le compte se cale à droite, avant le menu */ | |
| 1061 | +@media (max-width: 760px) { | |
| 1062 | + .login-btn, .account { margin-left: auto; } | |
| 1063 | + .login-btn + .menu-btn, .account + .menu-btn { margin-left: 10px; } | |
| 1064 | +} | |
| 1065 | + | |
| 1066 | +/* ================= Profil — carte de membre Groupe KA ================= */ | |
| 1067 | +.profil { padding-top: 40px; padding-bottom: 60px; } | |
| 1068 | +.profil h1 { font-size: clamp(30px, 5vw, 44px); margin: 10px 0 26px; } | |
| 1069 | +.profil h1 .hl { background: var(--lime); padding: 0 8px; border-radius: 8px; display: inline-block; } | |
| 1070 | +.profil .lede { max-width: 520px; color: var(--ink-2); margin-bottom: 22px; } | |
| 1071 | + | |
| 1072 | +.pc { | |
| 1073 | + position: relative; max-width: 520px; overflow: hidden; | |
| 1074 | + background: var(--ink); color: var(--paper); | |
| 1075 | + border: 2px solid var(--ink); border-radius: 16px; | |
| 1076 | + padding: 24px 26px 0; box-shadow: 10px 10px 0 rgba(20, 35, 26, 0.18); | |
| 1077 | +} | |
| 1078 | +.pc-watermark { | |
| 1079 | + position: absolute; right: -18px; top: -34px; pointer-events: none; | |
| 1080 | + font-family: var(--font-display); font-weight: 700; font-size: 170px; | |
| 1081 | + letter-spacing: -0.06em; color: rgba(217, 242, 107, 0.07); | |
| 1082 | + transform: rotate(-8deg); line-height: 1; | |
| 1083 | +} | |
| 1084 | +.pc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; } | |
| 1085 | +.pc-brand { font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.04em; } | |
| 1086 | +.pc-ka { background: var(--lime); color: var(--ink); padding: 1px 6px 3px; border-radius: 5px; margin-left: 3px; display: inline-block; transform: rotate(-2deg); } | |
| 1087 | +.pc-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.16em; color: rgba(250, 246, 238, 0.55); } | |
| 1088 | +.pc-id-block { margin: 26px 0 22px; display: flex; flex-direction: column; gap: 4px; } | |
| 1089 | +.pc-id-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.2em; color: rgba(217, 242, 107, 0.65); } | |
| 1090 | +.pc-id { | |
| 1091 | + font-family: var(--font-mono); font-weight: 700; color: var(--lime); | |
| 1092 | + font-size: clamp(22px, 6vw, 34px); letter-spacing: 0.14em; | |
| 1093 | + text-shadow: 0 0 24px rgba(217, 242, 107, 0.35); | |
| 1094 | +} | |
| 1095 | +.pc-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; padding-bottom: 18px; } | |
| 1096 | +.pc-holder { display: flex; flex-direction: column; gap: 2px; min-width: 0; } | |
| 1097 | +.pc-holder-name { font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 1098 | +.pc-holder-since { font-family: var(--font-mono); font-size: 10.5px; color: rgba(250, 246, 238, 0.5); letter-spacing: 0.06em; } | |
| 1099 | +.pc-role-badge { | |
| 1100 | + display: inline-block; width: fit-content; margin: 3px 0 2px; | |
| 1101 | + background: var(--lime); color: var(--ink); | |
| 1102 | + font-family: var(--font-mono); font-size: 9px; font-weight: 700; | |
| 1103 | + text-transform: uppercase; letter-spacing: 0.12em; | |
| 1104 | + padding: 2px 7px 3px; border-radius: 5px; transform: rotate(-1deg); | |
| 1105 | +} | |
| 1106 | +.pc-avatar { width: 52px; height: 52px; border-radius: 999px; border: 2px solid var(--lime); flex: none; } | |
| 1107 | +.pc-strip { display: flex; gap: 5px; margin: 0 -26px; padding: 9px 18px; background: rgba(217, 242, 107, 0.1); border-top: 1px solid rgba(217, 242, 107, 0.25); overflow: hidden; } | |
| 1108 | +.pc-strip i { display: block; width: 3px; border-radius: 1px; background: var(--lime); opacity: 0.7; } | |
| 1109 | +.pc-strip i:nth-child(3n) { height: 14px; opacity: 0.35; } | |
| 1110 | +.pc-strip i:nth-child(3n+1) { height: 9px; } | |
| 1111 | +.pc-strip i:nth-child(3n+2) { height: 17px; opacity: 0.9; } | |
| 1112 | +.pc-copy { margin-top: 16px; } | |
| 1113 | +.pc-copy.ok { background: var(--lime); border-color: var(--ink); color: var(--ink); } | |
| 1114 | + | |
| 1115 | +.profil-grid { | |
| 1116 | + display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); | |
| 1117 | + gap: 12px; margin-top: 30px; max-width: 760px; | |
| 1118 | +} | |
| 1119 | +.pg-item { | |
| 1120 | + background: var(--surface); border: 1.5px solid var(--ink); | |
| 1121 | + border-radius: var(--r-card); padding: 14px 16px; | |
| 1122 | + display: flex; flex-direction: column; gap: 4px; box-shadow: var(--shadow-flat); | |
| 1123 | +} | |
| 1124 | +.pg-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); } | |
| 1125 | +.pg-value { font-weight: 600; font-size: 14.5px; word-break: break-word; } | |
| 1126 | +.pg-value.mono { font-family: var(--font-mono); color: var(--green-deep); } | |
| 1127 | + | |
| 1128 | +/* profil géré au hub Groupe KA — lecture seule */ | |
| 1129 | +.hub-profile { | |
| 1130 | + margin-top: 34px; padding-top: 24px; border-top: 2px solid var(--ink); | |
| 1131 | + max-width: 640px; display: flex; flex-direction: column; gap: 14px; | |
| 1132 | +} | |
| 1133 | +.hub-profile h3 { font-size: 18px; } | |
| 1134 | +.hub-profile .hub-bio { color: var(--ink-2); font-size: 14px; margin: 0; max-width: 560px; } | |
| 1135 | +.hub-profile .pub-meta { display: flex; gap: 8px; flex-wrap: wrap; } | |
| 1136 | +.hub-profile .stat-chip { display: inline-flex; align-items: center; gap: 6px; } | |
| 1137 | +.hub-profile a.stat-chip:hover { background: var(--lime-soft); } | |
| 1138 | +.hub-profile .btn-primary { display: inline-flex; align-items: center; gap: 7px; } | |
| 1139 | +.hub-profile .pe-hint { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); } | |
| 1140 | + | |
| 1141 | +.profil-note { max-width: 640px; color: var(--ink-2); font-size: 13.5px; margin-top: 22px; } | |
| 1142 | +.profil-note a { text-decoration: underline; text-underline-offset: 3px; } | |
| 1143 | +.profil-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 26px; } | |
| 1144 | +.profil-actions .btn { display: inline-flex; align-items: center; gap: 7px; } | |
| 991 | 1145 | |