mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-19 05:06:56 +02:00
Les données de la grille (ventes 12 mois, stock, marges, gammes, métriques réseau Qlik) n'étaient accessibles par aucun moyen programmatique, et getProductRows() exige un fournisseur : chercher un produit sans le connaître était impossible. Contrainte de conception : l'API ne recalcule jamais rien. La grille était reconstruite en direct et gardée seulement 10 min en mémoire ; une API qui appellerait getProductRows() serait lente et imprévisible. On persiste donc le résultat d'un calcul qui a déjà lieu, et on le sert. - Table grid_rows : colonnes scalaires (filtre/tri/recherche en SQL) + payload jsonb du ProductRow complet. Remplie en effet de bord NON bloquant par getProductRows(), purge des articles disparus via computed_at. Survit aux redémarrages, contrairement au cache mémoire. - Endpoints /api/v1 : fournisseurs, grid, products/search (transversale, tous fournisseurs), products/:codein, network/:codeCentrale, openapi.json. Pagination, tri sur liste blanche, projection de champs, validation zod. 202 not_ready si un fournisseur n'a pas encore d'instantané. - Authentification double : clé d'API (X-API-Key ou Bearer, SHA-256 en base, révocable) ou session existante. Le middleware exempte /api/v1 — sans quoi un script recevait une redirection 302 vers /login au lieu d'un 401 JSON. - Gestion des clés dans /settings (server actions, clé affichée une seule fois). Vérifié contre une base PostgreSQL locale : 401 JSON sans clé, 401 sur clé révoquée, recherche renvoyant plusieurs fournisseurs, upsert + purge, et 24 appels /api/v1 sans déclencher un seul recalcul (l'ancienne route /api/grid/rows en déclenche un à chaque appel). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
118 lines
4.1 KiB
TypeScript
118 lines
4.1 KiB
TypeScript
"use server";
|
|
|
|
/**
|
|
* CollectFlow — Gestion des clés de l'API `/api/v1` (administrateurs).
|
|
*
|
|
* La clé en clair n'existe qu'une fois : elle est renvoyée par `createApiKey()` et
|
|
* jamais restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée
|
|
* conserve sa ligne, pour garder la trace de son usage passé.
|
|
*/
|
|
|
|
import { db } from "@/db";
|
|
import { apiKeys } from "@/db/schema";
|
|
import { desc, eq } from "drizzle-orm";
|
|
import { auth } from "@/lib/auth";
|
|
import { generateApiKey } from "@/lib/api-auth";
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
async function ensureAdmin() {
|
|
const session = await auth();
|
|
if ((session?.user as { role?: string } | undefined)?.role !== "admin") {
|
|
throw new Error("Accès refusé : Droits administrateur requis.");
|
|
}
|
|
return session;
|
|
}
|
|
|
|
export interface ApiKeyRow {
|
|
id: number;
|
|
name: string;
|
|
keyPrefix: string;
|
|
role: string;
|
|
createdBy: string | null;
|
|
createdAt: string | null;
|
|
lastUsedAt: string | null;
|
|
revokedAt: string | null;
|
|
}
|
|
|
|
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
|
|
export async function getApiKeys(): Promise<ApiKeyRow[]> {
|
|
await ensureAdmin();
|
|
const rows = await db
|
|
.select({
|
|
id: apiKeys.id,
|
|
name: apiKeys.name,
|
|
keyPrefix: apiKeys.keyPrefix,
|
|
role: apiKeys.role,
|
|
createdBy: apiKeys.createdBy,
|
|
createdAt: apiKeys.createdAt,
|
|
lastUsedAt: apiKeys.lastUsedAt,
|
|
revokedAt: apiKeys.revokedAt,
|
|
})
|
|
.from(apiKeys)
|
|
.orderBy(desc(apiKeys.createdAt));
|
|
|
|
return rows.map((r) => ({
|
|
...r,
|
|
createdAt: r.createdAt ? r.createdAt.toISOString() : null,
|
|
lastUsedAt: r.lastUsedAt ? r.lastUsedAt.toISOString() : null,
|
|
revokedAt: r.revokedAt ? r.revokedAt.toISOString() : null,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Crée une clé et renvoie sa valeur **en clair, une seule et unique fois**.
|
|
* L'appelant doit la présenter immédiatement à l'utilisateur : elle est irrécupérable.
|
|
*/
|
|
export async function createApiKey(
|
|
name: string,
|
|
role: "admin" | "user" = "user",
|
|
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
|
|
const session = await ensureAdmin();
|
|
|
|
const trimmed = name.trim();
|
|
if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." };
|
|
if (trimmed.length > 100) return { success: false, error: "Nom trop long (100 caractères max)." };
|
|
|
|
try {
|
|
const { key, keyHash, keyPrefix } = generateApiKey();
|
|
await db.insert(apiKeys).values({
|
|
name: trimmed,
|
|
keyPrefix,
|
|
keyHash,
|
|
role,
|
|
createdBy: (session?.user as { name?: string | null } | undefined)?.name ?? null,
|
|
});
|
|
revalidatePath("/settings");
|
|
return { success: true, key, keyPrefix };
|
|
} catch (err) {
|
|
console.error("[api-keys] création KO:", err);
|
|
return { success: false, error: "Erreur technique lors de la création de la clé." };
|
|
}
|
|
}
|
|
|
|
/** Révoque une clé : elle est refusée dès l'appel suivant, mais reste listée. */
|
|
export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
|
await ensureAdmin();
|
|
try {
|
|
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
|
|
revalidatePath("/settings");
|
|
return { success: true };
|
|
} catch (err) {
|
|
console.error("[api-keys] révocation KO:", err);
|
|
return { success: false, error: "Erreur lors de la révocation." };
|
|
}
|
|
}
|
|
|
|
/** Supprime définitivement une clé révoquée (nettoyage de la liste). */
|
|
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
|
await ensureAdmin();
|
|
try {
|
|
await db.delete(apiKeys).where(eq(apiKeys.id, id));
|
|
revalidatePath("/settings");
|
|
return { success: true };
|
|
} catch (err) {
|
|
console.error("[api-keys] suppression KO:", err);
|
|
return { success: false, error: "Erreur lors de la suppression." };
|
|
}
|
|
}
|