SPB Git forge

spb/groupe-ka

Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%

Jeton API personnel (API·Ka) par KA ID

- db: colonnes api_token/api_token_created + ensureApiToken/rotateApiToken/findUserByApiToken (kapi_ + 48 hex)
- /api/account/token (GET crée/lit, POST régénère — session requise)
- /api/sso/token-verify (HMAC client, même schéma que /api/sso/profile) pour api-ka
- /compte: section « API·Ka — jeton d accès personnel » (masqué/copier/régénérer) + plateformes manquantes (resto/sorti/crea/job/trouve/api)

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

5 changed files +230 −0

added src/app/api/account/token/route.ts +20 −0
@@ -0,0 +1,20 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Jeton API personnel (API·Ka) — GET: lit (le crée au besoin) ;
3 +// POST: régénère (l'ancien devient immédiatement invalide).
4 +import { NextResponse } from "next/server";
5 +import { getSessionUser } from "@/lib/auth";
6 +import { ensureApiToken, rotateApiToken } from "@/lib/db";
7 +
8 +export async function GET() {
9 + const user = await getSessionUser();
10 + if (!user)
11 + return NextResponse.json({ error: "Non connecté." }, { status: 401 });
12 + return NextResponse.json(ensureApiToken(user.id));
13 +}
14 +
15 +export async function POST() {
16 + const user = await getSessionUser();
17 + if (!user)
18 + return NextResponse.json({ error: "Non connecté." }, { status: 401 });
19 + return NextResponse.json(rotateApiToken(user.id));
20 +}
added src/app/api/sso/token-verify/route.ts +26 −0
@@ -0,0 +1,26 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Vérification serveur-à-serveur d'un jeton API personnel (kapi_…).
3 +// Utilisé par api-ka pour authentifier les appels Bearer de son API produit.
4 +// GET ?client_id&token&ts&sig sig = HMAC(secret_client, `${client_id}.${token}.${ts}`)
5 +// Réponse : { valid, ka_id?, name?, email?, role? }
6 +import { NextRequest, NextResponse } from "next/server";
7 +import { findUserByApiToken } from "@/lib/db";
8 +import { verifyClientSig } from "@/lib/sso";
9 +
10 +export async function GET(req: NextRequest) {
11 + const q = req.nextUrl.searchParams;
12 + const clientId = q.get("client_id") ?? "";
13 + const token = q.get("token") ?? "";
14 + // même schéma HMAC que /api/sso/profile : le jeton prend la place du ka_id
15 + if (!verifyClientSig(clientId, token, q.get("ts") ?? "", q.get("sig") ?? ""))
16 + return NextResponse.json({ error: "signature invalide" }, { status: 401 });
17 + const user = findUserByApiToken(token);
18 + if (!user || !user.ka_id) return NextResponse.json({ valid: false });
19 + return NextResponse.json({
20 + valid: true,
21 + ka_id: user.ka_id,
22 + name: user.name,
23 + email: user.email,
24 + role: user.role,
25 + });
26 +}
added src/app/compte/ApiTokenCard.tsx +90 −0
@@ -0,0 +1,90 @@
1 +"use client";
2 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 +// Jeton API personnel (API·Ka) — affichage masqué, copie, régénération.
4 +import { useState } from "react";
5 +
6 +export default function ApiTokenCard({
7 + token: initialToken,
8 + created: initialCreated,
9 +}: {
10 + token: string;
11 + created: string;
12 +}) {
13 + const [token, setToken] = useState(initialToken);
14 + const [created, setCreated] = useState(initialCreated);
15 + const [shown, setShown] = useState(false);
16 + const [copied, setCopied] = useState(false);
17 + const [busy, setBusy] = useState(false);
18 +
19 + const masked = token.slice(0, 10) + "…" + token.slice(-4);
20 +
21 + async function copy() {
22 + try {
23 + await navigator.clipboard.writeText(token);
24 + setCopied(true);
25 + setTimeout(() => setCopied(false), 1800);
26 + } catch {
27 + /* presse-papiers indisponible */
28 + }
29 + }
30 +
31 + async function regenerate() {
32 + if (
33 + !confirm(
34 + "Régénérer votre jeton API ? L'ancien jeton cessera de fonctionner immédiatement.",
35 + )
36 + )
37 + return;
38 + setBusy(true);
39 + try {
40 + const r = await fetch("/api/account/token", { method: "POST" });
41 + const j = (await r.json()) as { token?: string; created?: string };
42 + if (j.token) {
43 + setToken(j.token);
44 + setCreated(j.created ?? "");
45 + setShown(true);
46 + }
47 + } finally {
48 + setBusy(false);
49 + }
50 + }
51 +
52 + return (
53 + <div>
54 + <div className="mt-3 flex flex-wrap items-center gap-2">
55 + <code className="min-w-0 flex-1 rounded-md border-2 border-ink bg-[#faf9f5] px-3 py-2 font-mono text-[13px] break-all select-all">
56 + {shown ? token : masked}
57 + </code>
58 + </div>
59 + <div className="mt-3 flex flex-wrap gap-2">
60 + <button
61 + type="button"
62 + onClick={() => setShown((s) => !s)}
63 + className="btn btn-ghost text-[13px]"
64 + >
65 + {shown ? "Masquer" : "Afficher"}
66 + </button>
67 + <button
68 + type="button"
69 + onClick={copy}
70 + className="btn btn-ghost text-[13px]"
71 + >
72 + {copied ? "Copié ✓" : "Copier"}
73 + </button>
74 + <button
75 + type="button"
76 + onClick={regenerate}
77 + disabled={busy}
78 + className="btn btn-ghost text-[13px]"
79 + >
80 + {busy ? "…" : "↻ Régénérer"}
81 + </button>
82 + </div>
83 + {created ? (
84 + <p className="mt-3 text-[12px] text-ink-2">
85 + Généré le {created.slice(0, 10)} (UTC).
86 + </p>
87 + ) : null}
88 + </div>
89 + );
90 +}
modified src/app/compte/page.tsx +35 −0
@@ -12,6 +12,7 @@ import {
12 12 db,
13 13 parseSocials,
14 14 favoritesOf,
15 + ensureApiToken,
15 16 type UserRow,
16 17 type FavoriteRow,
17 18 } from "@/lib/db";
@@ -22,6 +23,7 @@ import AvatarEditor from "./AvatarEditor";
22 23 import RolePicker from "./RolePicker";
23 24 import ProfileForm from "./ProfileForm";
24 25 import CopyText from "./CopyText";
26 +import ApiTokenCard from "./ApiTokenCard";
25 27
26 28 export const metadata: Metadata = {
27 29 title: "Mon KA ID",
@@ -38,6 +40,12 @@ const PLATFORM_URLS: Record<string, string> = {
38 40 "auto-ka": "https://www.auto-ka.com",
39 41 "fabri-ka": "https://www.fabri-ka.com",
40 42 "food-ka": "https://www.food-ka.com",
43 + "resto-ka": "https://www.resto-ka.com",
44 + "sorti-ka": "https://www.sorti-ka.com",
45 + "crea-ka": "https://www.crea-ka.com",
46 + "job-ka": "https://www.job-ka.com",
47 + "trouve-ka": "https://www.trouve-ka.com",
48 + "api-ka": "https://www.api-ka.com",
41 49 "ora-ka": "https://www.ora-ka.com",
42 50 "toit-ka": "https://www.toit-ka.com",
43 51 };
@@ -194,6 +202,8 @@ export default async function Compte() {
194 202 const user = await getSessionUser();
195 203 if (!user) redirect("/connexion?next=%2Fcompte");
196 204 const kaId = user.kaId ?? ensureKaId(user.id);
205 + // Jeton API personnel (API·Ka) — créé à la première visite du compte
206 + const apiToken = ensureApiToken(user.id);
197 207 // Code QR de la carte → page de vérification publique du KA-ID
198 208 const qr = await QRCode.toDataURL(`https://www.groupe-ka.com/m/${kaId}`, {
199 209 margin: 0,
@@ -359,6 +369,31 @@ export default async function Compte() {
359 369 {/* ——— Mon univers Ka (favoris unifiés) ——— */}
360 370 <UniversSection userId={user.id} />
361 371
372 + {/* ——— Jeton API personnel (API·Ka) ——— */}
373 + <section className="rise mt-8 [animation-delay:0.255s]">
374 + <div className="gk-card p-5 sm:p-6">
375 + <p className="klabel">API·Ka — jeton d&apos;accès personnel</p>
376 + <p className="mt-2 text-[13.5px] leading-relaxed text-ink-2">
377 + L&apos;API du Groupe KA (
378 + <a
379 + href="https://www.api-ka.com"
380 + className="font-semibold underline underline-offset-4"
381 + target="_blank"
382 + rel="noopener"
383 + >
384 + www.api-ka.com
385 + </a>
386 + ) exige une connexion : envoyez ce jeton dans l&apos;en-tête{" "}
387 + <code className="font-mono text-[12.5px]">
388 + Authorization: Bearer …
389 + </code>{" "}
390 + — ou connectez-vous avec votre KA ID directement sur le site
391 + d&apos;API·Ka. Ce jeton est personnel : ne le partagez pas.
392 + </p>
393 + <ApiTokenCard token={apiToken.token} created={apiToken.created} />
394 + </div>
395 + </section>
396 +
362 397 {/* ——— Mon profil (source de vérité pour toutes les plateformes) ——— */}
363 398 <ProfileSection userId={user.id} kaId={kaId} />
364 399
modified src/lib/db.ts +59 −0
@@ -3,6 +3,7 @@
3 3 import Database from "better-sqlite3";
4 4 import path from "path";
5 5 import fs from "fs";
6 +import crypto from "crypto";
6 7
7 8 function open() {
8 9 const dbPath =
@@ -92,6 +93,19 @@ function open() {
92 93 );
93 94 CREATE INDEX IF NOT EXISTS favorites_user ON favorites(user_id, app);
94 95 `);
96 + // Jeton API personnel (2026-08-23) — requis pour consommer l'API produit
97 + // d'API·Ka (Authorization: Bearer kapi_…). Généré/affiché sur /compte,
98 + // vérifié par api-ka via /api/sso/token-verify (HMAC).
99 + for (const col of ["api_token TEXT", "api_token_created TEXT"]) {
100 + try {
101 + db.exec(`ALTER TABLE users ADD COLUMN ${col}`);
102 + } catch {
103 + /* colonne déjà présente */
104 + }
105 + }
106 + db.exec(
107 + "CREATE UNIQUE INDEX IF NOT EXISTS users_api_token ON users(api_token)",
108 + );
95 109 return db;
96 110 }
97 111
@@ -148,8 +162,53 @@ export type UserRow = {
148 162 birth_date: string | null;
149 163 socials: string | null; // JSON {instagram, facebook, x, linkedin, tiktok, youtube}
150 164 public: number;
165 + api_token: string | null;
166 + api_token_created: string | null;
151 167 };
152 168
169 +// ---------- jeton API personnel (API·Ka) ----------
170 +
171 +function randomApiToken(): string {
172 + return "kapi_" + crypto.randomBytes(24).toString("hex");
173 +}
174 +
175 +/** Jeton API du compte — le crée s'il manque. */
176 +export function ensureApiToken(id: number): { token: string; created: string } {
177 + const row = db
178 + .prepare("SELECT api_token, api_token_created FROM users WHERE id = ?")
179 + .get(id) as { api_token: string | null; api_token_created: string | null } | undefined;
180 + if (row?.api_token)
181 + return { token: row.api_token, created: row.api_token_created ?? "" };
182 + return rotateApiToken(id);
183 +}
184 +
185 +/** Régénère le jeton API (l'ancien devient immédiatement invalide). */
186 +export function rotateApiToken(id: number): { token: string; created: string } {
187 + for (;;) {
188 + const token = randomApiToken();
189 + try {
190 + db.prepare(
191 + "UPDATE users SET api_token = ?, api_token_created = datetime('now') WHERE id = ?",
192 + ).run(token, id);
193 + const created = (
194 + db
195 + .prepare("SELECT api_token_created AS c FROM users WHERE id = ?")
196 + .get(id) as { c: string }
197 + ).c;
198 + return { token, created };
199 + } catch {
200 + /* collision improbable : on retente */
201 + }
202 + }
203 +}
204 +
205 +export function findUserByApiToken(token: string): UserRow | undefined {
206 + if (!/^kapi_[0-9a-f]{48}$/.test(token)) return undefined;
207 + return db.prepare("SELECT * FROM users WHERE api_token = ?").get(token) as
208 + | UserRow
209 + | undefined;
210 +}
211 +
153 212 export const SOCIAL_KEYS = [
154 213 "instagram",
155 214 "facebook",
156 215