diff --git a/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx b/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx new file mode 100644 index 0000000..95bfc88 --- /dev/null +++ b/src/app/admin/carbets/[id]/_components/CarbetMemberships.tsx @@ -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(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 ( +
+ {linked.length === 0 ? ( +

+ Aucune organisation liée. Le carbet est géré uniquement par son propriétaire individuel. +

+ ) : ( + + )} + + {options.length > 0 ? ( +
+ + +
+ ) : ( +

+ Toutes les organisations existantes sont déjà liées à ce carbet. +

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

+ Une organisation liée signifie que ses CE_MANAGERs peuvent éditer ce carbet en plus du + propriétaire nominal. +

+
+ ); +} diff --git a/src/app/admin/carbets/[id]/page.tsx b/src/app/admin/carbets/[id]/page.tsx index bf7a972..6a69e2e 100644 --- a/src/app/admin/carbets/[id]/page.tsx +++ b/src/app/admin/carbets/[id]/page.tsx @@ -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 (
@@ -61,6 +78,25 @@ export default async function EditCarbetPage({ params }: PageProps) {
+
+

+ Organisations co-gestionnaires (CE) +

+ ({ + 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} + /> +
+

Médias diff --git a/src/app/admin/carbets/actions.ts b/src/app/admin/carbets/actions.ts index 2004bd8..f85950a 100644 --- a/src/app/admin/carbets/actions.ts +++ b/src/app/admin/carbets/actions.ts @@ -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, diff --git a/src/app/espace-ce/membres/_components/InviteForm.tsx b/src/app/espace-ce/membres/_components/InviteForm.tsx index 8fb63e3..4a8fd96 100644 --- a/src/app/espace-ce/membres/_components/InviteForm.tsx +++ b/src/app/espace-ce/membres/_components/InviteForm.tsx @@ -14,10 +14,13 @@ export function InviteForm({ const [pending, startTransition] = useTransition(); const [error, setError] = useState(null); const [link, setLink] = useState(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({

✓ Lien d'invitation généré (valable 14 jours) + {emailSent ? " · email envoyé" : ""}

{link} @@ -76,8 +81,9 @@ export function InviteForm({
) : 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. + Si vous indiquez un email, l'invitation sera envoyée automatiquement et le lien + sera bloqué pour toute autre adresse à la connexion. Sans email, 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 index e1e6fa3..b042c00 100644 --- a/src/app/espace-ce/membres/actions.ts +++ b/src/app/espace-ce/membres/actions.ts @@ -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 { + const intro = inviterName + ? `${inviterName} vous invite à rejoindre` + : "Vous êtes invité à rejoindre"; + await sendEmail({ + to, + subject: `Invitation à rejoindre « ${orgName} » sur Karbé`, + html: wrap( + `Invitation Karbé — ${orgName}`, + `

${intro} le Comité d'Entreprise ${orgName} sur Karbé.

+

Cliquez sur le bouton ci-dessous pour créer votre compte CE_MEMBER et accéder aux carbets et matériel de votre CE :

+

Rejoindre ${orgName}

+

Lien valable 14 jours. Si vous n'êtes pas le destinataire attendu, ignorez cet email.

+

Lien direct : ${inviteUrl}

`, + ), + text: `${intro.replace(/<[^>]+>/g, "")} le CE ${orgName} sur Karbé : ${inviteUrl}`, + }); +} + export async function sendCeApproved( to: string, firstName: string,