karbe/src/app/espace-ce/membres/page.tsx
Ubuntu ea0e606735
All checks were successful
CI / test (pull_request) Successful in 2m45s
feat(ce): Sprint K — public badge + invites CE_MEMBER + tests
Public badge sur fiche carbet :
- carbet-public.ts charge les OrganizationCarbetMembership (org
  approuvée uniquement) + expose `organizations: {id,name,slug}[]`.
- /carbets/[slug] affiche « Géré par le CE <name> » sous le header
  si au moins 1 org liée.

Invites CE_MEMBER :
- Migration 20260603300000_org_invite_token : OrgInviteToken
  (tokenHash, organizationId, email?, createdByUserId, expiresAt,
  usedAt). Cascade sur Organization. Index expiresAt + organizationId.
- src/lib/ce-invites.ts : createOrgInviteToken (TTL 14j),
  listOrgInviteTokens, getOrgInviteByToken (validité + expiry),
  markOrgInviteConsumed, revokeOrgInviteToken. Token = 24 bytes
  base64url, hash sha256.
- /espace-ce/membres : liste membres (CE_MANAGER + CE_MEMBER actifs)
  + form de génération de lien (email optionnel = lock email côté
  signup) + liste des invitations avec statut actif/consommé/expiré +
  bouton révoquer.
- /espace-ce/membres/actions.ts : createInviteAction +
  revokeInviteAction. Audit log scope=ce.invite.
- API /api/signup étendue : zod accepte inviteToken, branche dédiée
  qui crée User CE_MEMBER + organizationId du token + marquage
  usedAt. Vérif email match si email fourni dans le token.
- /inscription?invite=TOKEN : récupère l'invite, pré-affiche org name,
  lock email si fourni, masque les fieldsets type de compte (forcé
  CE_MEMBER).

CTA marketing :
- /pour-comites-entreprise : section CTA « Créer mon espace CE » sous
  le rendu content-pages, conditionnée par plugin ce-management.

Tests vitest (tests/lib/ce-access.test.ts) :
- canManageCarbet : admin always, owner direct, CE_MANAGER via org
  match, refus si autre org / pas d'org / TOURIST / pas de membership.
- 9 tests, mocks next-auth + @/auth + @/lib/authorization pour éviter
  next/server (incompatible vitest sans setup).
- Total tests projet : 62/62 ✓.

Dashboard /espace-ce : lien vers /espace-ce/membres en bas.

Migration prod appliquée.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 00:03:03 +00:00

173 lines
6.6 KiB
TypeScript

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>
);
}