feat(ce): Sprint L — email auto invites + admin memberships UI
All checks were successful
CI / test (pull_request) Successful in 2m39s
All checks were successful
CI / test (pull_request) Successful in 2m39s
Email automatique pour les invites CE_MEMBER : - sendCeInviteEmail(to, orgName, inviteUrl, inviterName?) : template best-effort (dry-run sans Resend), bouton CTA + lien direct en plain text. Mentionne TTL 14j + warning si pas le destinataire attendu. - createInviteAction branche l'envoi automatique quand un email est renseigné dans le formulaire. Audit log gagne emailedAutomatically. - InviteForm UI : affiche « lien généré · email envoyé » quand un email était fourni. Texte d'aide mis à jour. - Sans email → comportement inchangé : lien à copier manuellement. Admin /admin/carbets/[id] gagne section memberships : - src/lib/admin/carbets.ts : getCarbetForEdit inclut organizations + listOrganizationsForLink helper (toutes orgs triées approved desc). - 2 actions admin : linkCarbetToOrganizationAction (idempotent) + unlinkCarbetFromOrganizationAction. Audit scope=admin.carbets, events carbet.org.link / carbet.org.unlink. - CarbetMemberships client component : liste les orgs liées (badge pending si org non approuvée) + select des orgs disponibles + boutons Lier/Délier. Désactive le select quand toutes les orgs sont déjà liées. Le link admin permet de : - Lier rétroactivement un carbet existant à un CE (cas où l'orga intègre un carbet d'un hôte individuel). - Délier un carbet quand un CE part ou que le carbet repasse en gestion individuelle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2b8d786cf9
commit
3a557b6de5
7 changed files with 260 additions and 7 deletions
125
src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx
Normal file
125
src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue