From ea0e606735eacc623fb2ea84ed5ecbc1a8a3ac84 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 3 Jun 2026 00:03:03 +0000 Subject: [PATCH] =?UTF-8?q?feat(ce):=20Sprint=20K=20=E2=80=94=20public=20b?= =?UTF-8?q?adge=20+=20invites=20CE=5FMEMBER=20+=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 » 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) --- .../migration.sql | 22 +++ prisma/schema.prisma | 19 ++ src/app/api/signup/route.ts | 38 +++- src/app/carbets/[slug]/page.tsx | 11 ++ .../membres/_components/InviteForm.tsx | 84 +++++++++ src/app/espace-ce/membres/actions.ts | 70 +++++++ src/app/espace-ce/membres/page.tsx | 173 ++++++++++++++++++ src/app/espace-ce/page.tsx | 6 +- .../inscription/_components/SignupForm.tsx | 35 +++- src/app/inscription/page.tsx | 23 ++- src/app/pour-comites-entreprise/page.tsx | 29 ++- src/lib/carbet-public.ts | 13 ++ src/lib/ce-invites.ts | 68 +++++++ tests/lib/ce-access.test.ts | 111 +++++++++++ 14 files changed, 691 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/20260603300000_org_invite_token/migration.sql create mode 100644 src/app/espace-ce/membres/_components/InviteForm.tsx create mode 100644 src/app/espace-ce/membres/actions.ts create mode 100644 src/app/espace-ce/membres/page.tsx create mode 100644 src/lib/ce-invites.ts create mode 100644 tests/lib/ce-access.test.ts diff --git a/prisma/migrations/20260603300000_org_invite_token/migration.sql b/prisma/migrations/20260603300000_org_invite_token/migration.sql new file mode 100644 index 0000000..7ce99e5 --- /dev/null +++ b/prisma/migrations/20260603300000_org_invite_token/migration.sql @@ -0,0 +1,22 @@ +-- Sprint K : tokens d'invitation CE_MEMBER. +-- Le CE_MANAGER génère un lien /inscription?invite=TOKEN, le destinataire s'inscrit +-- automatiquement comme CE_MEMBER de l'organisation. usedAt à la consommation. + +CREATE TABLE "OrgInviteToken" ( + "tokenHash" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "email" TEXT, + "createdByUserId" TEXT, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "OrgInviteToken_pkey" PRIMARY KEY ("tokenHash") +); + +CREATE INDEX "OrgInviteToken_organizationId_idx" ON "OrgInviteToken"("organizationId"); +CREATE INDEX "OrgInviteToken_expiresAt_idx" ON "OrgInviteToken"("expiresAt"); + +ALTER TABLE "OrgInviteToken" + ADD CONSTRAINT "OrgInviteToken_organizationId_fkey" + FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 49f703a..4294008 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -86,11 +86,30 @@ model Organization { members User[] carbetMemberships OrganizationCarbetMembership[] rentalProviders RentalProvider[] + invites OrgInviteToken[] @@index([name]) @@index([approved]) } +/// Token d'invitation pour rejoindre une organisation comme CE_MEMBER. +/// Le CE_MANAGER génère un lien, le destinataire s'inscrit via /inscription?invite=TOKEN. +/// Pas de unique sur email pour permettre plusieurs invites pendants par destinataire. +model OrgInviteToken { + tokenHash String @id + organizationId String + email String? + createdByUserId String? + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@index([organizationId]) + @@index([expiresAt]) +} + /// Co-gestion des carbets côté CE. Un Carbet a toujours un ownerId (créateur initial), /// et zéro ou plusieurs orgs liées : un CE_MANAGER d'une org liée peut gérer le carbet /// en plus de l'owner. Pour un hôte individuel : aucune membership ; pour un carbet CE : diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts index e056d9b..739bf1b 100644 --- a/src/app/api/signup/route.ts +++ b/src/app/api/signup/route.ts @@ -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) => { diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index 640d7c6..dbaeeaf 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -115,6 +115,17 @@ export default async function PublicCarbetPage({ params }: PageProps) { ? ` · Route + ${formatPirogueDuration(carbet.pirogueDurationMin)} pirogue depuis ${carbet.embarkPoint}` : ` · Route directe (embarquement ${carbet.embarkPoint})`}

+ {carbet.organizations.length > 0 ? ( +

+ Géré par le CE{" "} + {carbet.organizations.map((o, i) => ( + + {o.name} + {i < carbet.organizations.length - 1 ? ", " : ""} + + ))} +

+ ) : null} {carbet.reviewStats.count > 0 && carbet.reviewStats.averageRating !== null ? (

diff --git a/src/app/espace-ce/membres/_components/InviteForm.tsx b/src/app/espace-ce/membres/_components/InviteForm.tsx new file mode 100644 index 0000000..8fb63e3 --- /dev/null +++ b/src/app/espace-ce/membres/_components/InviteForm.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState, useTransition } from "react"; + +import type { CreateInviteResult } from "../actions"; + +export function InviteForm({ + action, + siteUrl, +}: { + action: (fd: FormData) => Promise; + siteUrl: string; +}) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [link, setLink] = useState(null); + + function onSubmit(fd: FormData) { + setError(null); + setLink(null); + startTransition(async () => { + const res = await action(fd); + if (!res.ok) { + setError(res.error); + return; + } + setLink(`${siteUrl}/inscription?invite=${res.token}`); + }); + } + + return ( +

+
+ + +
+ {error ? ( +
+ {error} +
+ ) : null} + {link ? ( +
+

+ ✓ Lien d'invitation généré (valable 14 jours) +

+ + {link} + + +
+ ) : null} +

+ Si vous indiquez un email, le lien sera bloqué pour tout autre adresse à la connexion. + Sinon, n'importe qui ayant le lien peut rejoindre votre CE. +

+
+ ); +} diff --git a/src/app/espace-ce/membres/actions.ts b/src/app/espace-ce/membres/actions.ts new file mode 100644 index 0000000..e1e6fa3 --- /dev/null +++ b/src/app/espace-ce/membres/actions.ts @@ -0,0 +1,70 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { auth } from "@/auth"; +import { UserRole } from "@/generated/prisma/enums"; +import { recordAudit } from "@/lib/admin/audit"; +import { + createOrgInviteToken, + revokeOrgInviteToken, +} from "@/lib/ce-invites"; +import { getCurrentCeOrganization } from "@/lib/ce-access"; +import { prisma } from "@/lib/prisma"; + +export type CreateInviteResult = + | { ok: true; token: string } + | { ok: false; error: string }; + +export async function createInviteAction(fd: FormData): Promise { + const session = await auth(); + if (!session?.user?.id) return { ok: false, error: "Non authentifié." }; + if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) { + return { ok: false, error: "Réservé aux CE_MANAGER." }; + } + const org = await getCurrentCeOrganization(); + if (!org) return { ok: false, error: "Aucune organisation détectée." }; + if (!org.approved) return { ok: false, error: "Votre organisation doit être validée." }; + + const email = ((fd.get("email") as string | null) ?? "").trim().toLowerCase() || null; + if (email && !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) { + return { ok: false, error: "Email invalide." }; + } + + const token = await createOrgInviteToken({ + organizationId: org.id, + createdByUserId: session.user.id, + email, + }); + await recordAudit({ + scope: "ce.invite", + event: "invite.create", + target: org.id, + actorEmail: session.user.email ?? null, + details: { email }, + }); + revalidatePath("/espace-ce/membres"); + return { ok: true, token }; +} + +export async function revokeInviteAction(tokenHash: string): Promise { + const session = await auth(); + if (!session?.user?.id) return; + if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) return; + const org = await getCurrentCeOrganization(); + if (!org) return; + const invite = await prisma.orgInviteToken.findUnique({ + where: { tokenHash }, + select: { organizationId: true }, + }); + if (!invite || invite.organizationId !== org.id) return; + await revokeOrgInviteToken(tokenHash); + await recordAudit({ + scope: "ce.invite", + event: "invite.revoke", + target: org.id, + actorEmail: session.user.email ?? null, + details: {}, + }); + revalidatePath("/espace-ce/membres"); +} diff --git a/src/app/espace-ce/membres/page.tsx b/src/app/espace-ce/membres/page.tsx new file mode 100644 index 0000000..e77ed19 --- /dev/null +++ b/src/app/espace-ce/membres/page.tsx @@ -0,0 +1,173 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { UserRole } from "@/generated/prisma/enums"; +import { getCurrentCeOrganization } from "@/lib/ce-access"; +import { listOrgInviteTokens } from "@/lib/ce-invites"; +import { prisma } from "@/lib/prisma"; + +import { createInviteAction, revokeInviteAction } from "./actions"; +import { InviteForm } from "./_components/InviteForm"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Membres CE — Karbé" }; + +const ROLE_LABEL: Record = { + CE_MANAGER: "Manager", + CE_MEMBER: "Membre", +}; + +export default async function CeMembresPage() { + const org = await getCurrentCeOrganization(); + if (!org) redirect("/admin/organizations"); + + const [members, invites] = await Promise.all([ + prisma.user.findMany({ + where: { + organizationId: org.id, + role: { in: [UserRole.CE_MANAGER, UserRole.CE_MEMBER] }, + isActive: true, + }, + orderBy: [{ role: "asc" }, { lastName: "asc" }], + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + createdAt: true, + }, + }), + listOrgInviteTokens(org.id), + ]); + + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr"; + + const dateFmt = new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", + month: "short", + year: "2-digit", + }); + + return ( +
+
+ + ← Tableau de bord CE + +

+ Membres — {org.name} +

+

+ {members.length} membre{members.length > 1 ? "s" : ""} actif{members.length > 1 ? "s" : ""}. + Générez un lien d'invitation pour qu'un nouveau CE_MEMBER s'inscrive et + rejoigne automatiquement votre organisation. +

+
+ +
+

+ Inviter un membre +

+ {!org.approved ? ( +

+ 🕒 La génération d'invitations est bloquée tant que votre organisation n'est + pas validée. +

+ ) : ( +
+ +
+ )} +
+ +
+

+ Membres ({members.length}) +

+ {members.length === 0 ? ( +

Aucun membre actif pour l'instant.

+ ) : ( +
    + {members.map((m) => ( +
  • +
    +
    + {m.firstName} {m.lastName} +
    +
    {m.email}
    +
    + + {ROLE_LABEL[m.role] ?? m.role} + +
  • + ))} +
+ )} +
+ +
+

+ Invitations en cours ({invites.filter((i) => !i.usedAt && i.expiresAt > new Date()).length}) +

+ {invites.length === 0 ? ( +

Aucune invitation envoyée pour l'instant.

+ ) : ( +
    + {invites.map((inv) => { + const expired = inv.expiresAt < new Date(); + const used = inv.usedAt !== null; + const status = used ? "consommé" : expired ? "expiré" : "actif"; + return ( +
  • +
    +
    + {inv.email ?? "(lien partagé)"} +
    +
    + Créé {dateFmt.format(inv.createdAt)} · Expire {dateFmt.format(inv.expiresAt)} +
    +
    +
    + + {status} + + {!used && !expired ? ( +
    + +
    + ) : null} +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/app/espace-ce/page.tsx b/src/app/espace-ce/page.tsx index e6eaee6..0a5251d 100644 --- a/src/app/espace-ce/page.tsx +++ b/src/app/espace-ce/page.tsx @@ -85,7 +85,11 @@ export default async function CeDashboardPage() {

- Le bouton « Matériel rental » sera actif au Sprint J du plan CE management. + Gérez aussi vos{" "} + + membres et invitations CE + + .

); diff --git a/src/app/inscription/_components/SignupForm.tsx b/src/app/inscription/_components/SignupForm.tsx index 3ac9fc5..3bb3cd3 100644 --- a/src/app/inscription/_components/SignupForm.tsx +++ b/src/app/inscription/_components/SignupForm.tsx @@ -4,9 +4,11 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { signIn } from "next-auth/react"; -type Props = { next: string }; +type InviteContext = { token: string; orgName: string; emailLock?: string | null }; -export function SignupForm({ next }: Props) { +type Props = { next: string; invite?: InviteContext | null }; + +export function SignupForm({ next, invite }: Props) { const router = useRouter(); const [pending, startTransition] = useTransition(); const [error, setError] = useState(null); @@ -14,6 +16,7 @@ export function SignupForm({ next }: Props) { const [providerName, setProviderName] = useState(""); const [providerRivers, setProviderRivers] = useState(""); const [orgName, setOrgName] = useState(""); + const isInvite = Boolean(invite); function onSubmit(formData: FormData) { setError(null); @@ -55,6 +58,13 @@ export function SignupForm({ next }: Props) { if (role === "CE_MANAGER") { body.orgName = orgName.trim(); } + if (isInvite && invite) { + body.inviteToken = invite.token; + // L'API force le rôle CE_MEMBER quand inviteToken est valide ; + // on retire les champs inutiles pour ne pas créer de confusion. + delete (body as { providerName?: unknown }).providerName; + delete (body as { orgName?: unknown }).orgName; + } const res = await fetch("/api/signup", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -98,7 +108,17 @@ export function SignupForm({ next }: Props) { -
+ {isInvite ? ( +

+ Vous rejoignez {invite!.orgName} comme membre CE — les autres + types de compte sont masqués. +

+ ) : null} + +