feat(ce): Sprint K — public badge + invites CE_MEMBER + tests
All checks were successful
CI / test (push) Successful in 2m26s

This commit is contained in:
tarzzan 2026-06-03 00:03:38 +00:00
commit 2b8d786cf9
14 changed files with 691 additions and 11 deletions

View file

@ -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;

View file

@ -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 :

View file

@ -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) => {

View file

@ -115,6 +115,17 @@ export default async function PublicCarbetPage({ params }: PageProps) {
? ` · Route + ${formatPirogueDuration(carbet.pirogueDurationMin)} pirogue depuis ${carbet.embarkPoint}`
: ` · Route directe (embarquement ${carbet.embarkPoint})`}
</p>
{carbet.organizations.length > 0 ? (
<p className="mt-1 text-xs text-zinc-500">
Géré par le CE{" "}
{carbet.organizations.map((o, i) => (
<span key={o.id}>
<strong className="text-zinc-700">{o.name}</strong>
{i < carbet.organizations.length - 1 ? ", " : ""}
</span>
))}
</p>
) : null}
{carbet.reviewStats.count > 0 &&
carbet.reviewStats.averageRating !== null ? (
<p className="mt-2 flex items-center gap-2 text-sm text-zinc-700">

View file

@ -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<CreateInviteResult>;
siteUrl: string;
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [link, setLink] = useState<string | null>(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 (
<div className="space-y-3">
<form action={onSubmit} className="flex flex-wrap items-end gap-2">
<label className="block grow">
<span className="text-xs text-zinc-600">Email du futur CE_MEMBER (optionnel)</span>
<input
type="email"
name="email"
placeholder="prenom.nom@entreprise.gf"
maxLength={200}
className="mt-0.5 block w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none"
/>
</label>
<button
type="submit"
disabled={pending}
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-60"
>
{pending ? "Génération…" : "Générer un lien"}
</button>
</form>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">
{error}
</div>
) : null}
{link ? (
<div className="rounded-md border border-emerald-200 bg-emerald-50/60 p-3 text-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-800">
Lien d&apos;invitation généré (valable 14 jours)
</p>
<code className="mt-1 block break-all rounded bg-white px-2 py-1.5 font-mono text-xs text-zinc-700">
{link}
</code>
<button
type="button"
onClick={() => {
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
</button>
</div>
) : null}
<p className="text-[11px] text-zinc-500">
Si vous indiquez un email, le lien sera bloqué pour tout autre adresse à la connexion.
Sinon, n&apos;importe qui ayant le lien peut rejoindre votre CE.
</p>
</div>
);
}

View file

@ -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<CreateInviteResult> {
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<void> {
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");
}

View file

@ -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<string, string> = {
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 (
<main className="mx-auto max-w-4xl px-6 py-10 space-y-6">
<header>
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
Tableau de bord CE
</Link>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">
Membres {org.name}
</h1>
<p className="mt-1 text-sm text-zinc-600">
{members.length} membre{members.length > 1 ? "s" : ""} actif{members.length > 1 ? "s" : ""}.
Générez un lien d&apos;invitation pour qu&apos;un nouveau CE_MEMBER s&apos;inscrive et
rejoigne automatiquement votre organisation.
</p>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-500">
Inviter un membre
</h2>
{!org.approved ? (
<p className="mt-3 text-sm text-amber-900">
🕒 La génération d&apos;invitations est bloquée tant que votre organisation n&apos;est
pas validée.
</p>
) : (
<div className="mt-3">
<InviteForm action={createInviteAction} siteUrl={siteUrl} />
</div>
)}
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Membres ({members.length})
</h2>
{members.length === 0 ? (
<p className="text-sm text-zinc-500">Aucun membre actif pour l&apos;instant.</p>
) : (
<ul className="divide-y divide-zinc-100">
{members.map((m) => (
<li key={m.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<div>
<div className="font-medium text-zinc-900">
{m.firstName} {m.lastName}
</div>
<div className="text-xs text-zinc-500">{m.email}</div>
</div>
<span
className={
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider " +
(m.role === "CE_MANAGER"
? "bg-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-300"
: "bg-zinc-100 text-zinc-700 ring-1 ring-inset ring-zinc-300")
}
>
{ROLE_LABEL[m.role] ?? m.role}
</span>
</li>
))}
</ul>
)}
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Invitations en cours ({invites.filter((i) => !i.usedAt && i.expiresAt > new Date()).length})
</h2>
{invites.length === 0 ? (
<p className="text-sm text-zinc-500">Aucune invitation envoyée pour l&apos;instant.</p>
) : (
<ul className="divide-y divide-zinc-100">
{invites.map((inv) => {
const expired = inv.expiresAt < new Date();
const used = inv.usedAt !== null;
const status = used ? "consommé" : expired ? "expiré" : "actif";
return (
<li
key={inv.tokenHash}
className="flex items-center justify-between gap-3 py-2 text-sm"
>
<div>
<div className="font-mono text-xs text-zinc-700">
{inv.email ?? "(lien partagé)"}
</div>
<div className="text-[11px] text-zinc-500">
Créé {dateFmt.format(inv.createdAt)} · Expire {dateFmt.format(inv.expiresAt)}
</div>
</div>
<div className="flex items-center gap-2">
<span
className={
"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider " +
(status === "actif"
? "bg-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-300"
: status === "consommé"
? "bg-zinc-100 text-zinc-600 ring-1 ring-inset ring-zinc-300"
: "bg-amber-100 text-amber-800 ring-1 ring-inset ring-amber-300")
}
>
{status}
</span>
{!used && !expired ? (
<form action={revokeInviteAction.bind(null, inv.tokenHash)}>
<button
type="submit"
className="rounded border border-rose-200 bg-white px-2 py-0.5 text-[11px] text-rose-700 hover:bg-rose-50"
>
Révoquer
</button>
</form>
) : null}
</div>
</li>
);
})}
</ul>
)}
</section>
</main>
);
}

View file

@ -85,7 +85,11 @@ export default async function CeDashboardPage() {
</section>
<p className="text-xs text-zinc-500">
Le bouton « Matériel rental » sera actif au Sprint J du plan CE management.
Gérez aussi vos{" "}
<Link href="/espace-ce/membres" className="text-zinc-700 underline hover:text-zinc-900">
membres et invitations CE
</Link>
.
</p>
</main>
);

View file

@ -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<string | null>(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) {
<label className="block">
<span className="text-xs text-zinc-600">Email</span>
<input name="email" type="email" required maxLength={200} className={inputCls + " mt-0.5"} />
<input
name="email"
type="email"
required
maxLength={200}
defaultValue={invite?.emailLock ?? undefined}
readOnly={Boolean(invite?.emailLock)}
className={
inputCls + " mt-0.5" + (invite?.emailLock ? " bg-zinc-50 text-zinc-700" : "")
}
/>
</label>
<label className="block">
@ -118,7 +138,14 @@ export function SignupForm({ next }: Props) {
<input name="phone" type="tel" maxLength={40} className={inputCls + " mt-0.5"} />
</label>
<fieldset className="space-y-1">
{isInvite ? (
<p className="rounded-md border border-emerald-200 bg-emerald-50/60 px-3 py-2 text-xs text-emerald-900">
Vous rejoignez <strong>{invite!.orgName}</strong> comme membre CE les autres
types de compte sont masqués.
</p>
) : null}
<fieldset className="space-y-1" hidden={isInvite}>
<legend className="text-xs text-zinc-600">Type de compte</legend>
<div className="grid grid-cols-1 gap-2 pt-1 sm:grid-cols-2 lg:grid-cols-4">
<label

View file

@ -2,12 +2,14 @@ import { redirect } from "next/navigation";
import Link from "next/link";
import { auth } from "@/auth";
import { getOrgInviteByToken } from "@/lib/ce-invites";
import { SignupForm } from "./_components/SignupForm";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{ next?: string }>;
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 (
<main className="mx-auto flex min-h-[70vh] max-w-md items-center px-6 py-12">
<div className="w-full space-y-4 rounded-xl border border-zinc-200 bg-white p-6 shadow-sm">
<header>
<h1 className="text-2xl font-semibold text-zinc-900">Créer un compte</h1>
<p className="mt-1 text-sm text-zinc-500">
Un compte vous permet de réserver un séjour ou, en tant qu&apos;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."}
</p>
</header>
<SignupForm next={next} />
<SignupForm next={next} invite={invite} />
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
Déjà un compte ?{" "}

View file

@ -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 <ContentPageRenderer page={page} />;
const ceEnabled = await isPluginEnabled("ce-management");
return (
<>
<ContentPageRenderer page={page} />
{ceEnabled ? (
<section className="mx-auto my-8 max-w-3xl rounded-lg border border-emerald-200 bg-emerald-50/60 px-6 py-6 text-center">
<h2 className="text-lg font-semibold text-emerald-900">
Vous êtes un Comité d&apos;Entreprise ?
</h2>
<p className="mt-1 text-sm text-emerald-900">
Créez votre espace CE sur Karbé pour proposer vos carbets à vos membres et au public
touriste, et activer la location de matériel.
</p>
<Link
href="/inscription"
className="mt-3 inline-block rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Créer mon espace CE
</Link>
</section>
) : null}
</>
);
}

View file

@ -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,

68
src/lib/ce-invites.ts Normal file
View file

@ -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<string> {
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<void> {
const tokenHash = hashToken(plainToken);
await prisma.orgInviteToken.update({
where: { tokenHash },
data: { usedAt: new Date() },
});
}
export async function revokeOrgInviteToken(tokenHash: string): Promise<void> {
await prisma.orgInviteToken.delete({ where: { tokenHash } }).catch(() => {});
}

111
tests/lib/ce-access.test.ts Normal file
View file

@ -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<typeof canManageCarbet>[0], "u-other", [])).toBe(true);
expect(canManageCarbet(session as unknown as Parameters<typeof canManageCarbet>[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<typeof canManageCarbet>[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<typeof canManageCarbet>[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<typeof canManageCarbet>[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<typeof canManageCarbet>[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<typeof canManageCarbet>[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<typeof canManageCarbet>[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<typeof canManageCarbet>[0], "u-other", ["org-1"])).toBe(false);
expect(canManageCarbet(session as unknown as Parameters<typeof canManageCarbet>[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<typeof canManageCarbet>[0], "u-ce", [])).toBe(true);
});
});