feat(ce): Sprint K — public badge + invites CE_MEMBER + tests
All checks were successful
CI / test (pull_request) Successful in 2m45s
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>
This commit is contained in:
parent
ab1bbb5484
commit
ea0e606735
14 changed files with 691 additions and 11 deletions
|
|
@ -2,6 +2,7 @@ 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";
|
||||
|
|
@ -23,6 +24,7 @@ const schema = z.object({
|
|||
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) {
|
||||
|
|
@ -55,6 +57,23 @@ export async function POST(req: Request) {
|
|||
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 });
|
||||
|
|
@ -68,7 +87,24 @@ export async function POST(req: Request) {
|
|||
let createdOrgId: string | null = null;
|
||||
let user: { id: string; email: string; role: UserRole };
|
||||
|
||||
if (data.role === UserRole.CE_MANAGER) {
|
||||
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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue