feat(ce): Sprint L — email auto invites + admin memberships UI
All checks were successful
CI / test (push) Successful in 2m25s
All checks were successful
CI / test (push) Successful in 2m25s
This commit is contained in:
commit
7a12848b5b
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,
|
||||
|
|
|
|||
|
|
@ -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'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'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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue