feat(ce): Sprint L — email auto invites + admin memberships UI
All checks were successful
CI / test (push) Successful in 2m25s

This commit is contained in:
tarzzan 2026-06-03 01:59:53 +00:00
commit 7a12848b5b
7 changed files with 260 additions and 7 deletions

View file

@ -0,0 +1,125 @@
"use client";
import { useState, useTransition } from "react";
type Org = { id: string; name: string; slug: string; approved: boolean };
type LinkedOrg = Org & { addedAt: Date };
type Props = {
carbetId: string;
linked: LinkedOrg[];
available: Org[];
linkAction: (carbetId: string, orgId: string) => Promise<{ ok: true; alreadyLinked: boolean } | { ok: false; error?: string }>;
unlinkAction: (carbetId: string, orgId: string) => Promise<{ ok: true } | { ok: false; error?: string }>;
};
export function CarbetMemberships({
carbetId,
linked,
available,
linkAction,
unlinkAction,
}: Props) {
const [pending, startTransition] = useTransition();
const [selectedOrgId, setSelectedOrgId] = useState("");
const [error, setError] = useState<string | null>(null);
// Filtre les orgs non encore liées
const linkedIds = new Set(linked.map((l) => l.id));
const options = available.filter((o) => !linkedIds.has(o.id));
function link() {
if (!selectedOrgId) return;
setError(null);
startTransition(async () => {
const res = await linkAction(carbetId, selectedOrgId);
if (!res.ok) setError(res.error || "Échec de la liaison");
else setSelectedOrgId("");
});
}
function unlink(orgId: string) {
setError(null);
startTransition(async () => {
const res = await unlinkAction(carbetId, orgId);
if (!res.ok) setError(res.error || "Échec");
});
}
return (
<div className="space-y-3">
{linked.length === 0 ? (
<p className="text-sm text-zinc-500">
Aucune organisation liée. Le carbet est géré uniquement par son propriétaire individuel.
</p>
) : (
<ul className="divide-y divide-zinc-100 rounded-md border border-zinc-200 bg-white">
{linked.map((o) => (
<li
key={o.id}
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
>
<div>
<span className="font-medium text-zinc-900">{o.name}</span>
<span className="ml-2 text-[11px] text-zinc-500">/{o.slug}</span>
{!o.approved ? (
<span className="ml-2 rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
Pending
</span>
) : null}
</div>
<button
type="button"
disabled={pending}
onClick={() => unlink(o.id)}
className="rounded border border-rose-200 bg-white px-2 py-1 text-[11px] text-rose-700 hover:bg-rose-50 disabled:opacity-60"
>
Délier
</button>
</li>
))}
</ul>
)}
{options.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
<select
value={selectedOrgId}
onChange={(e) => setSelectedOrgId(e.target.value)}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value=""> Choisir une organisation à lier </option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.name} {o.approved ? "" : "(pending)"}
</option>
))}
</select>
<button
type="button"
disabled={pending || !selectedOrgId}
onClick={link}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{pending ? "…" : "Lier"}
</button>
</div>
) : (
<p className="text-[11px] text-zinc-500">
Toutes les organisations existantes sont déjà liées à ce carbet.
</p>
)}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">
{error}
</div>
) : null}
<p className="text-[11px] text-zinc-500">
Une organisation liée signifie que ses CE_MANAGERs peuvent éditer ce carbet en plus du
propriétaire nominal.
</p>
</div>
);
}

View file

@ -1,15 +1,23 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { MediaUploader } from "@/components/MediaUploader";
import { StatusBadge } from "@/components/admin/StatusBadge";
import {
getCarbetForEdit,
listOrganizationsForLink,
listOwners,
listPirogueProviders,
} from "@/lib/admin/carbets";
import { CarbetForm } from "../_components/CarbetForm";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { MediaUploader } from "@/components/MediaUploader";
import {
linkCarbetToOrganizationAction,
unlinkCarbetFromOrganizationAction,
updateCarbetAction,
} from "../actions";
import { CarbetMemberships } from "./_components/CarbetMemberships";
import { StatusActions } from "./_components/StatusActions";
import { updateCarbetAction } from "../actions";
export const dynamic = "force-dynamic";
@ -17,10 +25,11 @@ type PageProps = { params: Promise<{ id: string }> };
export default async function EditCarbetPage({ params }: PageProps) {
const { id } = await params;
const [carbet, owners, providers] = await Promise.all([
const [carbet, owners, providers, organizations] = await Promise.all([
getCarbetForEdit(id),
listOwners(),
listPirogueProviders(),
listOrganizationsForLink(),
]);
if (!carbet) notFound();
@ -28,6 +37,14 @@ export default async function EditCarbetPage({ params }: PageProps) {
"use server";
return await updateCarbetAction(id, fd);
};
const linkThis = async (carbetId: string, orgId: string) => {
"use server";
return await linkCarbetToOrganizationAction(carbetId, orgId);
};
const unlinkThis = async (carbetId: string, orgId: string) => {
"use server";
return await unlinkCarbetFromOrganizationAction(carbetId, orgId);
};
return (
<div className="mx-auto max-w-5xl space-y-6">
@ -61,6 +78,25 @@ export default async function EditCarbetPage({ params }: PageProps) {
</div>
</header>
<section className="mb-6 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">
Organisations co-gestionnaires (CE)
</h2>
<CarbetMemberships
carbetId={carbet.id}
linked={carbet.organizations.map((m) => ({
id: m.organization.id,
name: m.organization.name,
slug: m.organization.slug,
approved: m.organization.approved,
addedAt: m.addedAt,
}))}
available={organizations}
linkAction={linkThis}
unlinkAction={unlinkThis}
/>
</section>
<section className="mb-6 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">
Médias

View file

@ -213,6 +213,42 @@ export async function reorderMediaAction(carbetId: string, mediaId: string, dire
return { ok: true as const };
}
export async function linkCarbetToOrganizationAction(carbetId: string, organizationId: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actorEmail = session?.user?.email ?? null;
// findFirst pour idempotence : si déjà lié, on ne touche pas + on ne crash pas.
const existing = await prisma.organizationCarbetMembership.findUnique({
where: { organizationId_carbetId: { organizationId, carbetId } },
select: { organizationId: true },
});
if (existing) {
return { ok: true as const, alreadyLinked: true };
}
await prisma.organizationCarbetMembership.create({
data: {
organizationId,
carbetId,
addedByUserId: session?.user?.id ?? null,
},
});
await audit("carbet.org.link", carbetId, actorEmail, { organizationId });
revalidatePath(`/admin/carbets/${carbetId}`);
return { ok: true as const, alreadyLinked: false };
}
export async function unlinkCarbetFromOrganizationAction(carbetId: string, organizationId: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actorEmail = session?.user?.email ?? null;
await prisma.organizationCarbetMembership
.delete({ where: { organizationId_carbetId: { organizationId, carbetId } } })
.catch(() => {});
await audit("carbet.org.unlink", carbetId, actorEmail, { organizationId });
revalidatePath(`/admin/carbets/${carbetId}`);
return { ok: true as const };
}
async function audit(
event: string,
entityId: string,

View file

@ -14,10 +14,13 @@ export function InviteForm({
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [link, setLink] = useState<string | null>(null);
const [emailSent, setEmailSent] = useState(false);
function onSubmit(fd: FormData) {
setError(null);
setLink(null);
setEmailSent(false);
const emailValue = ((fd.get("email") as string | null) ?? "").trim();
startTransition(async () => {
const res = await action(fd);
if (!res.ok) {
@ -25,6 +28,7 @@ export function InviteForm({
return;
}
setLink(`${siteUrl}/inscription?invite=${res.token}`);
setEmailSent(Boolean(emailValue));
});
}
@ -58,6 +62,7 @@ export function InviteForm({
<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)
{emailSent ? " · email envoyé" : ""}
</p>
<code className="mt-1 block break-all rounded bg-white px-2 py-1.5 font-mono text-xs text-zinc-700">
{link}
@ -76,8 +81,9 @@ export function InviteForm({
</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.
Si vous indiquez un email, l&apos;invitation sera envoyée automatiquement et le lien
sera bloqué pour toute autre adresse à la connexion. Sans email, n&apos;importe qui
ayant le lien peut rejoindre votre CE.
</p>
</div>
);

View file

@ -10,8 +10,11 @@ import {
revokeOrgInviteToken,
} from "@/lib/ce-invites";
import { getCurrentCeOrganization } from "@/lib/ce-access";
import { sendCeInviteEmail } from "@/lib/email";
import { prisma } from "@/lib/prisma";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
export type CreateInviteResult =
| { ok: true; token: string }
| { ok: false; error: string };
@ -41,8 +44,17 @@ export async function createInviteAction(fd: FormData): Promise<CreateInviteResu
event: "invite.create",
target: org.id,
actorEmail: session.user.email ?? null,
details: { email },
details: { email, emailedAutomatically: Boolean(email) },
});
// Envoi automatique si email destinataire fourni (best-effort, dry-run sans Resend).
if (email) {
const inviteUrl = `${SITE_URL}/inscription?invite=${token}`;
try {
await sendCeInviteEmail(email, org.name, inviteUrl, session.user.name);
} catch (e) {
console.error("[ce.invite] email send failed:", e instanceof Error ? e.message : e);
}
}
revalidatePath("/espace-ce/membres");
return { ok: true, token };
}

View file

@ -111,10 +111,24 @@ export async function getCarbetForEdit(id: string) {
owner: { select: { id: true, firstName: true, lastName: true, email: true } },
pirogueProvider: { select: { id: true, name: true } },
media: { orderBy: { sortOrder: "asc" } },
organizations: {
orderBy: { addedAt: "asc" },
include: {
organization: { select: { id: true, name: true, slug: true, approved: true } },
},
},
_count: { select: { bookings: true, reviews: true } },
},
});
}
/** Liste les orgs disponibles pour link sur un carbet — toutes orgs (approuvées et pending). */
export async function listOrganizationsForLink() {
return prisma.organization.findMany({
orderBy: [{ approved: "desc" }, { name: "asc" }],
select: { id: true, name: true, slug: true, approved: true },
});
}
// Options enum déplacées dans `./carbet-options.ts` pour être importables
// depuis les composants client (ce fichier-ci est server-only).

View file

@ -207,6 +207,30 @@ export async function sendNewCeRequest(
});
}
export async function sendCeInviteEmail(
to: string,
orgName: string,
inviteUrl: string,
inviterName?: string | null,
): Promise<void> {
const intro = inviterName
? `<strong>${inviterName}</strong> vous invite à rejoindre`
: "Vous êtes invité à rejoindre";
await sendEmail({
to,
subject: `Invitation à rejoindre « ${orgName} » sur Karbé`,
html: wrap(
`Invitation Karbé — ${orgName}`,
`<p>${intro} le Comité d'Entreprise <strong>${orgName}</strong> sur Karbé.</p>
<p>Cliquez sur le bouton ci-dessous pour créer votre compte CE_MEMBER et accéder aux carbets et matériel de votre CE :</p>
<p><a href="${inviteUrl}" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Rejoindre ${orgName}</a></p>
<p style="font-size:12px;color:#71717a;">Lien valable 14 jours. Si vous n'êtes pas le destinataire attendu, ignorez cet email.</p>
<p style="font-size:11px;color:#a1a1aa;word-break:break-all;">Lien direct : ${inviteUrl}</p>`,
),
text: `${intro.replace(/<[^>]+>/g, "")} le CE ${orgName} sur Karbé : ${inviteUrl}`,
});
}
export async function sendCeApproved(
to: string,
firstName: string,