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}
+
+
{
+ if (typeof navigator !== "undefined" && navigator.clipboard) {
+ navigator.clipboard.writeText(link).catch(() => {});
+ }
+ }}
+ className="mt-2 rounded border border-emerald-300 bg-white px-2 py-1 text-[11px] text-emerald-800 hover:bg-emerald-100"
+ >
+ Copier
+
+
+ ) : 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 (
+
+
+
+
+
+ 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.
+ ) : (
+
+ )}
+
+
+ );
+}
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) {
Email
-
+
@@ -118,7 +138,14 @@ export function SignupForm({ next }: Props) {
-
+ {isInvite ? (
+
+ Vous rejoignez {invite!.orgName} comme membre CE — les autres
+ types de compte sont masqués.
+
+ ) : null}
+
+
Type de compte
;
+ searchParams: Promise<{ next?: string; invite?: string }>;
};
export default async function SignupPage({ searchParams }: PageProps) {
@@ -16,17 +18,32 @@ export default async function SignupPage({ searchParams }: PageProps) {
const next = sp.next && sp.next.startsWith("/") ? sp.next : "/";
if (session?.user?.id) redirect(next);
+ // Si un token d'invitation valide est présent, on pré-remplit le contexte CE_MEMBER.
+ let invite: { token: string; orgName: string; emailLock?: string | null } | null = null;
+ if (sp.invite) {
+ const found = await getOrgInviteByToken(sp.invite);
+ if (found) {
+ invite = {
+ token: sp.invite,
+ orgName: found.organization.name,
+ emailLock: found.email,
+ };
+ }
+ }
+
return (
Créer un compte
- Un compte vous permet de réserver un séjour ou, en tant qu'hôte, de publier votre carbet.
+ {invite
+ ? `Vous avez été invité à rejoindre « ${invite.orgName} » comme membre CE.`
+ : "Un compte vous permet de réserver un séjour ou, en tant qu'hôte, de publier votre carbet."}
-
+
Déjà un compte ?{" "}
diff --git a/src/app/pour-comites-entreprise/page.tsx b/src/app/pour-comites-entreprise/page.tsx
index f0125bd..fa83f77 100644
--- a/src/app/pour-comites-entreprise/page.tsx
+++ b/src/app/pour-comites-entreprise/page.tsx
@@ -1,8 +1,10 @@
+import Link from "next/link";
import { notFound } from "next/navigation";
+
+import { ContentPageRenderer } from "@/components/ContentPageRenderer";
import { getContentPage } from "@/lib/content-pages";
import { getLocale } from "@/lib/i18n/server";
import { isPluginEnabled } from "@/lib/plugins/server";
-import { ContentPageRenderer } from "@/components/ContentPageRenderer";
export const dynamic = "force-dynamic";
@@ -15,5 +17,28 @@ export default async function CEPage() {
if (!(await isPluginEnabled("content-pages"))) notFound();
const page = await getContentPage("pour-comites-entreprise", await getLocale());
if (!page) notFound();
- return ;
+ const ceEnabled = await isPluginEnabled("ce-management");
+
+ return (
+ <>
+
+ {ceEnabled ? (
+
+
+ Vous êtes un Comité d'Entreprise ?
+
+
+ Créez votre espace CE sur Karbé pour proposer vos carbets à vos membres et au public
+ touriste, et activer la location de matériel.
+
+
+ Créer mon espace CE
+
+
+ ) : null}
+ >
+ );
}
diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts
index c09b2fb..3a947f8 100644
--- a/src/lib/carbet-public.ts
+++ b/src/lib/carbet-public.ts
@@ -42,6 +42,8 @@ export type PublicCarbetDetail = {
longitude: string;
ownerId: string;
ownerFirstName: string;
+ /** Comités d'Entreprise qui co-gèrent ce carbet (vide si hôte individuel). */
+ organizations: { id: string; name: string; slug: string }[];
media: PublicCarbetMedia[];
amenities: { key: string; label: string }[];
reviewStats: CarbetReviewStats;
@@ -99,6 +101,12 @@ export const getPublicCarbet = cache(
amenities: {
select: { amenity: { select: { key: true, label: true } } },
},
+ organizations: {
+ where: { organization: { approved: true } },
+ select: {
+ organization: { select: { id: true, name: true, slug: true } },
+ },
+ },
},
});
@@ -146,6 +154,11 @@ export const getPublicCarbet = cache(
longitude: carbet.longitude.toString(),
ownerId: carbet.ownerId,
ownerFirstName: carbet.owner.firstName,
+ organizations: carbet.organizations.map((m) => ({
+ id: m.organization.id,
+ name: m.organization.name,
+ slug: m.organization.slug,
+ })),
media: carbet.media.map((m) => ({
id: m.id,
type: m.type,
diff --git a/src/lib/ce-invites.ts b/src/lib/ce-invites.ts
new file mode 100644
index 0000000..d288971
--- /dev/null
+++ b/src/lib/ce-invites.ts
@@ -0,0 +1,68 @@
+import "server-only";
+
+import crypto from "node:crypto";
+
+import { prisma } from "@/lib/prisma";
+
+const INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 jours
+
+function hashToken(token: string): string {
+ return crypto.createHash("sha256").update(token).digest("hex");
+}
+
+export async function createOrgInviteToken(opts: {
+ organizationId: string;
+ createdByUserId: string;
+ email?: string | null;
+ ttlMs?: number;
+}): Promise {
+ const token = crypto.randomBytes(24).toString("base64url");
+ const tokenHash = hashToken(token);
+ const expiresAt = new Date(Date.now() + (opts.ttlMs ?? INVITE_TTL_MS));
+ await prisma.orgInviteToken.create({
+ data: {
+ tokenHash,
+ organizationId: opts.organizationId,
+ createdByUserId: opts.createdByUserId,
+ email: opts.email ?? null,
+ expiresAt,
+ },
+ });
+ return token;
+}
+
+export async function listOrgInviteTokens(organizationId: string) {
+ return prisma.orgInviteToken.findMany({
+ where: { organizationId },
+ orderBy: { createdAt: "desc" },
+ take: 50,
+ });
+}
+
+/** Renvoie l'invitation si elle existe, non expirée et non consommée. */
+export async function getOrgInviteByToken(plainToken: string) {
+ const tokenHash = hashToken(plainToken);
+ const row = await prisma.orgInviteToken.findUnique({
+ where: { tokenHash },
+ include: {
+ organization: { select: { id: true, name: true, slug: true, approved: true } },
+ },
+ });
+ if (!row) return null;
+ if (row.usedAt) return null;
+ if (row.expiresAt < new Date()) return null;
+ return row;
+}
+
+/** Marque l'invitation comme consommée. À appeler dans la transaction de signup. */
+export async function markOrgInviteConsumed(plainToken: string): Promise {
+ const tokenHash = hashToken(plainToken);
+ await prisma.orgInviteToken.update({
+ where: { tokenHash },
+ data: { usedAt: new Date() },
+ });
+}
+
+export async function revokeOrgInviteToken(tokenHash: string): Promise {
+ await prisma.orgInviteToken.delete({ where: { tokenHash } }).catch(() => {});
+}
diff --git a/tests/lib/ce-access.test.ts b/tests/lib/ce-access.test.ts
new file mode 100644
index 0000000..ecb89ad
--- /dev/null
+++ b/tests/lib/ce-access.test.ts
@@ -0,0 +1,111 @@
+import { describe, it, expect, vi } from "vitest";
+
+// L'enum est aussi un type ; on l'importe de manière statique pour TS.
+import { UserRole } from "@/generated/prisma/enums";
+
+// next-auth tire next/server qui n'est pas résolu dans le tunnel vitest.
+// On stubbe les modules nécessaires avant d'importer carbet-access (qui
+// importe Session de next-auth uniquement en type-only, mais authorization.ts
+// dépend de auth() — d'où le mock).
+vi.mock("next-auth", () => ({ default: () => ({}) }));
+vi.mock("@/auth", () => ({ auth: () => Promise.resolve(null) }));
+vi.mock("@/lib/authorization", () => ({
+ requireRole: () => Promise.resolve({}),
+}));
+
+const { canManageCarbet } = await import("@/lib/carbet-access");
+
+// Pure-data shape qui satisfait la signature de canManageCarbet sans tirer
+// next-auth/server (incompatible vitest sans setup).
+type MinimalSession = {
+ user: {
+ id: string;
+ role: UserRole;
+ organizationId?: string | null;
+ email?: string | null;
+ };
+};
+
+function makeSession(opts: {
+ userId: string;
+ role: UserRole;
+ organizationId?: string | null;
+}): MinimalSession {
+ return {
+ user: {
+ id: opts.userId,
+ role: opts.role,
+ organizationId: opts.organizationId ?? null,
+ email: "test@example.com",
+ },
+ };
+}
+
+describe("canManageCarbet", () => {
+ it("admin can always manage", () => {
+ const session = makeSession({ userId: "u-admin", role: UserRole.ADMIN });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-other", [])).toBe(true);
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-other", ["org-x"])).toBe(true);
+ });
+
+ it("owner can manage their own carbet", () => {
+ const session = makeSession({ userId: "u1", role: UserRole.OWNER });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u1", [])).toBe(true);
+ });
+
+ it("owner cannot manage someone else's carbet", () => {
+ const session = makeSession({ userId: "u1", role: UserRole.OWNER });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u2", [])).toBe(false);
+ });
+
+ it("CE_MANAGER can manage carbet linked to their org via membership", () => {
+ const session = makeSession({
+ userId: "u-ce",
+ role: UserRole.CE_MANAGER,
+ organizationId: "org-1",
+ });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-creator", ["org-1"])).toBe(true);
+ });
+
+ it("CE_MANAGER cannot manage carbet of another org", () => {
+ const session = makeSession({
+ userId: "u-ce",
+ role: UserRole.CE_MANAGER,
+ organizationId: "org-1",
+ });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-creator", ["org-2"])).toBe(false);
+ });
+
+ it("CE_MANAGER cannot manage when carbet has no memberships", () => {
+ const session = makeSession({
+ userId: "u-ce",
+ role: UserRole.CE_MANAGER,
+ organizationId: "org-1",
+ });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-creator", [])).toBe(false);
+ });
+
+ it("CE_MANAGER without organizationId cannot manage anything via membership", () => {
+ const session = makeSession({
+ userId: "u-ce",
+ role: UserRole.CE_MANAGER,
+ organizationId: null,
+ });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-creator", ["org-1"])).toBe(false);
+ });
+
+ it("TOURIST cannot manage", () => {
+ const session = makeSession({ userId: "u1", role: UserRole.TOURIST });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-other", ["org-1"])).toBe(false);
+ expect(canManageCarbet(session as unknown as Parameters[0], "u1", ["org-1"])).toBe(true); // matches as owner
+ });
+
+ it("CE_MANAGER can also manage as direct owner (rare but possible)", () => {
+ const session = makeSession({
+ userId: "u-ce",
+ role: UserRole.CE_MANAGER,
+ organizationId: "org-1",
+ });
+ expect(canManageCarbet(session as unknown as Parameters[0], "u-ce", [])).toBe(true);
+ });
+});