src/app/api/admin/auth/vitrine/route.ts
Annotation non disponible
Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.
Concepts détectés — comprends la théorie
ORM Prisma
5 occurrencesCe fichier accède à la base de données via Prisma. Prisma est l'ORM utilisé côté backend pour les requêtes typées sur PostgreSQL.
Voir l'article général
Route API Next.js
3 occurrencesCe fichier est une route API Next.js (App Router). Voir le contrat API complet pour les conventions de réponse et d'auth.
Voir l'article général
JWT / Auth backend
3 occurrencesCe fichier touche au système d'authentification (JWT, session, getSessionFromRequest). Voir le contrat API pour la logique complète.
Voir l'article général
2 exports
POSTdynamic
Code source· typescript· tronqué à 200 lignes sur 223
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma/client";
import { isValidCodeFormat, normalizeCode } from "@/lib/admin/code-acces";
import { createSession } from "@/lib/auth/session";
import { envoyerBienvenueAdmin } from "@/lib/email";
import { SignJWT } from "jose";
export const dynamic = "force-dynamic";
const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is required");
}
const secret = new TextEncoder().encode(NEXTAUTH_SECRET);
const SUBDOMAIN_REGEX = /^[a-z0-9](?:[a-z0-9-]{1,28}[a-z0-9])?$/;
const COULEUR_REGEX = /^#[0-9a-fA-F]{6}$/;
const MAX_CATEGORIES = 3;
// POST /api/admin/auth/vitrine
// Body: { code, nomVitrine, subdomain, logoUrl?, categoriesIds[], couleurAccent }
// - Update tenant : nom, subdomain (le permanent), logoUrl, couleurAccent, modules
// - Set TenantCategorieWari
// - Transition onboardingStep → DONE + codeAcces = null
// - Crée la session JWT pour login auto
// - Envoie email bienvenue best-effort
export async function POST(req: NextRequest) {
let body: {
code?: string;
nomVitrine?: string;
subdomain?: string;
logoUrl?: string | null;
categoriesIds?: string[];
couleurAccent?: string;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Body invalide" }, { status: 400 });
}
const code = normalizeCode(body.code ?? "");
const nomVitrine = (body.nomVitrine ?? "").trim();
const subdomain = (body.subdomain ?? "").trim().toLowerCase();
const logoUrl = body.logoUrl ?? null;
const categoriesIds = Array.isArray(body.categoriesIds) ? body.categoriesIds : [];
const couleurAccent = (body.couleurAccent ?? "#C9A227").trim();
if (!isValidCodeFormat(code)) {
return NextResponse.json({ error: "Code d'accès invalide" }, { status: 400 });
}
if (nomVitrine.length < 2 || nomVitrine.length > 60) {
return NextResponse.json({ error: "Le nom de vitrine doit faire 2 à 60 caractères" }, { status: 400 });
}
if (!SUBDOMAIN_REGEX.test(subdomain) || subdomain.length < 3 || subdomain.length > 30) {
return NextResponse.json({ error: "Sous-domaine invalide" }, { status: 400 });
}
if (!COULEUR_REGEX.test(couleurAccent)) {
return NextResponse.json({ error: "Couleur invalide (format #RRGGBB)" }, { status: 400 });
}
if (categoriesIds.length > MAX_CATEGORIES) {
return NextResponse.json(
{ error: `Maximum ${MAX_CATEGORIES} catégories` },
{ status: 400 }
);
}
const tenant = await prisma.tenant.findUnique({
where: { codeAcces: code },
select: {
id: true,
actif: true,
onboardingStep: true,
modulesPrevus: true,
users: {
where: { isPrimary: true },
select: { id: true, email: true, username: true, motDePasseHash: true, role: true },
take: 1,
},
},
});
if (!tenant) {
return NextResponse.json({ error: "Code invalide" }, { status: 404 });
}
if (!tenant.actif || tenant.onboardingStep === "DONE") {
return NextResponse.json({ error: "Setup non disponible" }, { status: 422 });
}
const primary = tenant.users[0];
if (!primary || !primary.motDePasseHash) {
return NextResponse.json(
{ error: "Termine d'abord les étapes email + username + mot de passe" },
{ status: 422 }
);
}
// Vérifier subdomain encore dispo (race condition)
const subdomainTaken = await prisma.tenant.findFirst({
where: { subdomain, NOT: { id: tenant.id } },
select: { id: true },
});
if (subdomainTaken) {
return NextResponse.json(
{ error: "Sous-domaine déjà pris, choisis-en un autre" },
{ status: 409 }
);
}
// Vérifier catégories existent
if (categoriesIds.length > 0) {
const found = await prisma.categorieWari.count({
where: { id: { in: categoriesIds } },
});
if (found !== categoriesIds.length) {
return NextResponse.json({ error: "Catégorie inconnue" }, { status: 400 });
}
}
// Update transactionnel
await prisma.$transaction(async (tx) => {
await tx.tenant.update({
where: { id: tenant.id },
data: {
nom: nomVitrine,
subdomain,
logoUrl,
couleurAccent,
onboardingStep: "DONE",
codeAcces: null, // consommé
vitrineActive: true, // BUG-146 — wizard couvre logo+subdomain+catégories+couleur → publié par défaut
},
});
// Reset puis re-insert TenantCategorieWari
await tx.tenantCategorieWari.deleteMany({ where: { tenantId: tenant.id } });
if (categoriesIds.length > 0) {
await tx.tenantCategorieWari.createMany({
data: categoriesIds.map((catId) => ({ tenantId: tenant.id, categorieWariId: catId })),
});
}
});
// Crée session JWT (login auto web)
await createSession(primary.id, primary.role, tenant.id);
// BUG-138 — Bearer JWT pour le mobile (parité /api/mobile/vitrine/login)
const bearerToken = await new SignJWT({
userId: primary.id,
role: primary.role,
tenantId: tenant.id,
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("30d")
.sign(secret);
// Fetch tenant enrichi pour le mobile (format aligné /api/mobile/vitrine/login)
const tenantForMobile = await prisma.tenant.findUnique({
where: { id: tenant.id },
select: {
id: true,
subdomain: true,
nom: true,
description: true,
logoUrl: true,
banniere: true,
whatsapp: true,
ville: true,
pays: true,
themeCouleur: true,
couleurAccent: true,
vitrineActive: true,
modeVitrineSiteWeb: true,
siteWebUrl: true,
modules: { where: { actif: true }, select: { nom: true } },
},
});
// Email bienvenue best-effort
try {
await envoyerBienvenueAdmin({
email: primary.email,
tenantNom: nomVitrine,
subdomain,
username: primary.username ?? primary.email,
});
} catch (e) {
console.error("Erreur email bienvenue:", e);
}
return NextResponse.json({
ok: true,
subdomain,
dashboardUrl: "/admin",
bearerToken,
user: {
id: primary.id,
email: primary.email,
nom: primary.username,
role: primary.role,
tenantId: tenant.id,import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma/client";
import { isValidCodeFormat, normalizeCode } from "@/lib/admin/code-acces";
import { createSession } from "@/lib/auth/session";
import { envoyerBienvenueAdmin } from "@/lib/email";
import { SignJWT } from "jose";
export const dynamic = "force-dynamic";
const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is required");
}
const secret = new TextEncoder().encode(NEXTAUTH_SECRET);
const SUBDOMAIN_REGEX = /^[a-z0-9](?:[a-z0-9-]{1,28}[a-z0-9])?$/;
const COULEUR_REGEX = /^#[0-9a-fA-F]{6}$/;
const MAX_CATEGORIES = 3;
// POST /api/admin/auth/vitrine
// Body: { code, nomVitrine, subdomain, logoUrl?, categoriesIds[], couleurAccent }
// - Update tenant : nom, subdomain (le permanent), logoUrl, couleurAccent, modules
// - Set TenantCategorieWari
// - Transition onboardingStep → DONE + codeAcces = null
// - Crée la session JWT pour login auto
// - Envoie email bienvenue best-effort
export async function POST(req: NextRequest) {
let body: {
code?: string;
nomVitrine?: string;
subdomain?: string;
logoUrl?: string | null;
categoriesIds?: string[];
couleurAccent?: string;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Body invalide" }, { status: 400 });
}
const code = normalizeCode(body.code ?? "");
const nomVitrine = (body.nomVitrine ?? "").trim();
const subdomain = (body.subdomain ?? "").trim().toLowerCase();
const logoUrl = body.logoUrl ?? null;
const categoriesIds = Array.isArray(body.categoriesIds) ? body.categoriesIds : [];
const couleurAccent = (body.couleurAccent ?? "#C9A227").trim();
if (!isValidCodeFormat(code)) {
return NextResponse.json({ error: "Code d'accès invalide" }, { status: 400 });
}
if (nomVitrine.length < 2 || nomVitrine.length > 60) {
return NextResponse.json({ error: "Le nom de vitrine doit faire 2 à 60 caractères" }, { status: 400 });
}
if (!SUBDOMAIN_REGEX.test(subdomain) || subdomain.length < 3 || subdomain.length > 30) {
return NextResponse.json({ error: "Sous-domaine invalide" }, { status: 400 });
}
if (!COULEUR_REGEX.test(couleurAccent)) {
return NextResponse.json({ error: "Couleur invalide (format #RRGGBB)" }, { status: 400 });
}
if (categoriesIds.length > MAX_CATEGORIES) {
return NextResponse.json(
{ error: `Maximum ${MAX_CATEGORIES} catégories` },
{ status: 400 }
);
}
const tenant = await prisma.tenant.findUnique({
where: { codeAcces: code },
select: {
id: true,
actif: true,
onboardingStep: true,
modulesPrevus: true,
users: {
where: { isPrimary: true },
select: { id: true, email: true, username: true, motDePasseHash: true, role: true },
take: 1,
},
},
});
if (!tenant) {
return NextResponse.json({ error: "Code invalide" }, { status: 404 });
}
if (!tenant.actif || tenant.onboardingStep === "DONE") {
return NextResponse.json({ error: "Setup non disponible" }, { status: 422 });
}
const primary = tenant.users[0];
if (!primary || !primary.motDePasseHash) {
return NextResponse.json(
{ error: "Termine d'abord les étapes email + username + mot de passe" },
{ status: 422 }
);
}
// Vérifier subdomain encore dispo (race condition)
const subdomainTaken = await prisma.tenant.findFirst({
where: { subdomain, NOT: { id: tenant.id } },
select: { id: true },
});
if (subdomainTaken) {
return NextResponse.json(
{ error: "Sous-domaine déjà pris, choisis-en un autre" },
{ status: 409 }
);
}
// Vérifier catégories existent
if (categoriesIds.length > 0) {
const found = await prisma.categorieWari.count({
where: { id: { in: categoriesIds } },
});
if (found !== categoriesIds.length) {
return NextResponse.json({ error: "Catégorie inconnue" }, { status: 400 });
}
}
// Update transactionnel
await prisma.$transaction(async (tx) => {
await tx.tenant.update({
where: { id: tenant.id },
data: {
nom: nomVitrine,
subdomain,
logoUrl,
couleurAccent,
onboardingStep: "DONE",
codeAcces: null, // consommé
vitrineActive: true, // BUG-146 — wizard couvre logo+subdomain+catégories+couleur → publié par défaut
},
});
// Reset puis re-insert TenantCategorieWari
await tx.tenantCategorieWari.deleteMany({ where: { tenantId: tenant.id } });
if (categoriesIds.length > 0) {
await tx.tenantCategorieWari.createMany({
data: categoriesIds.map((catId) => ({ tenantId: tenant.id, categorieWariId: catId })),
});
}
});
// Crée session JWT (login auto web)
await createSession(primary.id, primary.role, tenant.id);
// BUG-138 — Bearer JWT pour le mobile (parité /api/mobile/vitrine/login)
const bearerToken = await new SignJWT({
userId: primary.id,
role: primary.role,
tenantId: tenant.id,
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("30d")
.sign(secret);
// Fetch tenant enrichi pour le mobile (format aligné /api/mobile/vitrine/login)
const tenantForMobile = await prisma.tenant.findUnique({
where: { id: tenant.id },
select: {
id: true,
subdomain: true,
nom: true,
description: true,
logoUrl: true,
banniere: true,
whatsapp: true,
ville: true,
pays: true,
themeCouleur: true,
couleurAccent: true,
vitrineActive: true,
modeVitrineSiteWeb: true,
siteWebUrl: true,
modules: { where: { actif: true }, select: { nom: true } },
},
});
// Email bienvenue best-effort
try {
await envoyerBienvenueAdmin({
email: primary.email,
tenantNom: nomVitrine,
subdomain,
username: primary.username ?? primary.email,
});
} catch (e) {
console.error("Erreur email bienvenue:", e);
}
return NextResponse.json({
ok: true,
subdomain,
dashboardUrl: "/admin",
bearerToken,
user: {
id: primary.id,
email: primary.email,
nom: primary.username,
role: primary.role,
tenantId: tenant.id,