All checks were successful
CI / test (pull_request) Successful in 2m45s
Public badge sur fiche carbet :
- carbet-public.ts charge les OrganizationCarbetMembership (org
approuvée uniquement) + expose `organizations: {id,name,slug}[]`.
- /carbets/[slug] affiche « Géré par le CE <name> » sous le header
si au moins 1 org liée.
Invites CE_MEMBER :
- Migration 20260603300000_org_invite_token : OrgInviteToken
(tokenHash, organizationId, email?, createdByUserId, expiresAt,
usedAt). Cascade sur Organization. Index expiresAt + organizationId.
- src/lib/ce-invites.ts : createOrgInviteToken (TTL 14j),
listOrgInviteTokens, getOrgInviteByToken (validité + expiry),
markOrgInviteConsumed, revokeOrgInviteToken. Token = 24 bytes
base64url, hash sha256.
- /espace-ce/membres : liste membres (CE_MANAGER + CE_MEMBER actifs)
+ form de génération de lien (email optionnel = lock email côté
signup) + liste des invitations avec statut actif/consommé/expiré +
bouton révoquer.
- /espace-ce/membres/actions.ts : createInviteAction +
revokeInviteAction. Audit log scope=ce.invite.
- API /api/signup étendue : zod accepte inviteToken, branche dédiée
qui crée User CE_MEMBER + organizationId du token + marquage
usedAt. Vérif email match si email fourni dans le token.
- /inscription?invite=TOKEN : récupère l'invite, pré-affiche org name,
lock email si fourni, masque les fieldsets type de compte (forcé
CE_MEMBER).
CTA marketing :
- /pour-comites-entreprise : section CTA « Créer mon espace CE » sous
le rendu content-pages, conditionnée par plugin ce-management.
Tests vitest (tests/lib/ce-access.test.ts) :
- canManageCarbet : admin always, owner direct, CE_MANAGER via org
match, refus si autre org / pas d'org / TOURIST / pas de membership.
- 9 tests, mocks next-auth + @/auth + @/lib/authorization pour éviter
next/server (incompatible vitest sans setup).
- Total tests projet : 62/62 ✓.
Dashboard /espace-ce : lien vers /espace-ce/membres en bas.
Migration prod appliquée.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
199 lines
7 KiB
TypeScript
199 lines
7 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
|
|
import { UserRole } from "@/generated/prisma/enums";
|
|
import { getOrgInviteByToken, markOrgInviteConsumed } from "@/lib/ce-invites";
|
|
import { hashPassword } from "@/lib/password";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { recordAudit } from "@/lib/admin/audit";
|
|
import { sendNewCeRequest, sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
|
|
import { rateLimitRequest } from "@/lib/rate-limit";
|
|
import { slugify } from "@/lib/slug";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
const schema = z.object({
|
|
email: z.string().trim().toLowerCase().email().max(200),
|
|
password: z.string().min(8).max(200),
|
|
firstName: z.string().trim().min(1).max(100),
|
|
lastName: z.string().trim().min(1).max(100),
|
|
phone: z.string().trim().max(40).optional().nullable(),
|
|
role: z
|
|
.enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER, UserRole.CE_MANAGER])
|
|
.default(UserRole.TOURIST),
|
|
providerName: z.string().trim().min(2).max(200).optional(),
|
|
providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
|
|
orgName: z.string().trim().min(2).max(200).optional(),
|
|
inviteToken: z.string().trim().min(8).max(200).optional(),
|
|
});
|
|
|
|
export async function POST(req: Request) {
|
|
const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5);
|
|
if (!rl.ok) {
|
|
return NextResponse.json(
|
|
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
|
|
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
|
|
);
|
|
}
|
|
let body: unknown;
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
|
|
}
|
|
const parsed = schema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
const data = parsed.data;
|
|
|
|
if (data.role === UserRole.RENTAL_PROVIDER && (!data.providerName || data.providerName.trim().length < 2)) {
|
|
return NextResponse.json({ error: "Nom de votre activité requis." }, { status: 400 });
|
|
}
|
|
if (data.role === UserRole.CE_MANAGER && (!data.orgName || data.orgName.trim().length < 2)) {
|
|
return NextResponse.json({ error: "Nom de votre Comité d'Entreprise requis." }, { status: 400 });
|
|
}
|
|
|
|
// Invitation CE_MEMBER : si un inviteToken est fourni, on force le rôle CE_MEMBER
|
|
// et on rattache à l'org du token (org déjà validée — pas de bannière pending).
|
|
let inviteOrgId: string | null = null;
|
|
if (data.inviteToken) {
|
|
const invite = await getOrgInviteByToken(data.inviteToken);
|
|
if (!invite) {
|
|
return NextResponse.json({ error: "Lien d'invitation invalide ou expiré." }, { status: 400 });
|
|
}
|
|
if (invite.email && invite.email.toLowerCase() !== data.email) {
|
|
return NextResponse.json(
|
|
{ error: "Ce lien d'invitation est réservé à un autre email." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
inviteOrgId = invite.organizationId;
|
|
}
|
|
|
|
const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } });
|
|
if (existing) {
|
|
return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 });
|
|
}
|
|
|
|
const passwordHash = await hashPassword(data.password);
|
|
|
|
// CE_MANAGER : transaction atomique User + Organization. Le slug est unique
|
|
// sur Organization → on retente avec un suffixe en cas de collision.
|
|
let createdProviderId: string | null = null;
|
|
let createdOrgId: string | null = null;
|
|
let user: { id: string; email: string; role: UserRole };
|
|
|
|
if (inviteOrgId) {
|
|
// Branche invite CE_MEMBER : rattache le user à l'org du token, ignore data.role.
|
|
user = await prisma.user.create({
|
|
data: {
|
|
email: data.email,
|
|
passwordHash,
|
|
firstName: data.firstName,
|
|
lastName: data.lastName,
|
|
phone: data.phone?.trim() || null,
|
|
role: UserRole.CE_MEMBER,
|
|
organizationId: inviteOrgId,
|
|
isActive: true,
|
|
},
|
|
select: { id: true, email: true, role: true },
|
|
});
|
|
createdOrgId = inviteOrgId;
|
|
await markOrgInviteConsumed(data.inviteToken!).catch(() => {});
|
|
} else if (data.role === UserRole.CE_MANAGER) {
|
|
const orgName = data.orgName!.trim();
|
|
const baseSlug = slugify(orgName);
|
|
const result = await prisma.$transaction(async (tx) => {
|
|
// Trouve un slug libre
|
|
let candidate = baseSlug || "ce";
|
|
let suffix = 1;
|
|
for (;;) {
|
|
const exists = await tx.organization.findUnique({ where: { slug: candidate }, select: { id: true } });
|
|
if (!exists) break;
|
|
suffix += 1;
|
|
candidate = `${baseSlug}-${suffix}`;
|
|
}
|
|
// candidate now holds a free slug
|
|
const org = await tx.organization.create({
|
|
data: {
|
|
name: orgName,
|
|
slug: candidate,
|
|
contactEmail: data.email,
|
|
approved: false,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
const u = await tx.user.create({
|
|
data: {
|
|
email: data.email,
|
|
passwordHash,
|
|
firstName: data.firstName,
|
|
lastName: data.lastName,
|
|
phone: data.phone?.trim() || null,
|
|
role: UserRole.CE_MANAGER,
|
|
organizationId: org.id,
|
|
isActive: true,
|
|
},
|
|
select: { id: true, email: true, role: true },
|
|
});
|
|
return { user: u, orgId: org.id };
|
|
});
|
|
user = result.user;
|
|
createdOrgId = result.orgId;
|
|
sendNewCeRequest(orgName, user.email).catch(() => {});
|
|
} else {
|
|
user = await prisma.user.create({
|
|
data: {
|
|
email: data.email,
|
|
passwordHash,
|
|
firstName: data.firstName,
|
|
lastName: data.lastName,
|
|
phone: data.phone?.trim() || null,
|
|
role: data.role,
|
|
isActive: true,
|
|
},
|
|
select: { id: true, email: true, role: true },
|
|
});
|
|
|
|
// Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation.
|
|
if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) {
|
|
const provider = await prisma.rentalProvider.create({
|
|
data: {
|
|
name: data.providerName,
|
|
isSystemD: false,
|
|
managedByUserId: user.id,
|
|
contactEmail: user.email,
|
|
contactPhone: data.phone?.trim() || null,
|
|
rivers: data.providerRivers ?? [],
|
|
commissionPct: 10, // valeur par défaut, ajustable par admin
|
|
active: true,
|
|
approved: false,
|
|
},
|
|
select: { id: true, name: true },
|
|
});
|
|
createdProviderId = provider.id;
|
|
sendNewRentalProviderRequest(provider.name, user.email).catch(() => {});
|
|
}
|
|
}
|
|
|
|
await recordAudit({
|
|
scope: "public.signup",
|
|
event: "user.create",
|
|
target: user.id,
|
|
actorEmail: user.email,
|
|
details: { role: user.role, rentalProviderId: createdProviderId, organizationId: createdOrgId },
|
|
});
|
|
|
|
sendSignupWelcome(user.email, data.firstName).catch(() => {});
|
|
|
|
return NextResponse.json({
|
|
ok: true,
|
|
userId: user.id,
|
|
providerId: createdProviderId,
|
|
organizationId: createdOrgId,
|
|
});
|
|
}
|